diff --git a/README.md b/README.md index a2f1432..8b5dcf9 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Most coding agents start every session from zero. memcode keeps a persistent model of your repo in `.memcode`: the subsystems, what you worked on last week, which approaches failed and why, and the preferences you have corrected it on. The longer you use it, the less you have to explain. -One Go binary, two ways to run it. **Code** is the interactive agent in your terminal. **Agents** is the same binary as a self-hosted gateway, answering on the chat surfaces you already use. Both run against whatever models you have: your own API keys, a local endpoint like Ollama, or a hosted memcode account. +One Go binary, two ways to run it. **Code** is the interactive agent in your terminal. **Agents** is the same binary as a self-hosted gateway, answering on the chat surfaces you already use — and running agents you have given a standing objective and permission to work on it unattended. Both run against whatever models you have: your own API keys, a local endpoint like Ollama, or a hosted memcode account. ## Screenshots @@ -56,6 +56,25 @@ Message your agent from wherever you already are. It runs your task and replies **Coming from Hermes or OpenClaw?** `memcode hermes migrate` or `memcode claw migrate` brings over your channels, API keys, skills, and long-term memory in one command. +## Agents that run on their own + +An agent can be given a durable **objective** and permission to run +**autonomously** — then it works on that objective on a schedule, with nobody +watching. It is the same agent either way; autonomy is a setting, not a +separate kind. You set it up by talking to `memcode admin`. + +Objective and autonomy are separate grants on purpose: an agent may hold a goal +you only ever work on together, and an agent may run unattended on a schedule +with no standing objective at all. The second case is why this matters — an +unattended run is policy-gated (authority approved in advance, by hash), +journals every consequential action, confines file access to explicit grants, +and can suspend durably to ask you something rather than guessing. Plain +scheduled agents never had any of that. + +It can also delegate real work to a scoped worker with browser, MCP, shell and +filesystem access, and drive your own signed-in Chrome rather than a +logged-out profile. See `docs/autonomous-agents.md`. + ## Install ```bash diff --git a/cmd/admin_autonomy.go b/cmd/admin_autonomy.go new file mode 100644 index 0000000..f839b86 --- /dev/null +++ b/cmd/admin_autonomy.go @@ -0,0 +1,373 @@ +package cmd + +// Admin handlers for agents that run unattended: the delegation policy that +// bounds them, the resources they may reach, on-demand wakes, the durable +// question inbox, the action journal, and health checks. +// +// These are ordinary admin tools, dispatched from adminExecute alongside +// gw_channel and gw_schedule. There is deliberately no separate cockpit: an +// autonomous agent is an agent with an objective, an approved policy, and +// permission to act on its own — not a different species with its own +// management surface. + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/memcode-ai/memcode/internal/agent/autonomy" + "github.com/memcode-ai/memcode/internal/atomicfile" + "github.com/memcode-ai/memcode/internal/browser" + "github.com/memcode-ai/memcode/internal/browser/broker" + gwconfig "github.com/memcode-ai/memcode/internal/gateway/config" + "github.com/memcode-ai/memcode/internal/llm" + "github.com/memcode-ai/memcode/internal/mcp" + "github.com/memcode-ai/memcode/internal/provider" +) + +// agentStore opens the autonomy store for a configured agent. The store is +// created lazily, so an ordinary conversational agent never gets one. +func agentStore(ctx context.Context, agent string) (*autonomy.Store, string, gwconfig.Agent, error) { + s, err := gwconfig.Load() + if err != nil { + return nil, "", gwconfig.Agent{}, err + } + a, ok := s.Agents[agent] + if !ok { + return nil, "", gwconfig.Agent{}, fmt.Errorf("no agent %q", agent) + } + home, err := gwconfig.AgentHome(agent) + if err != nil { + return nil, "", gwconfig.Agent{}, err + } + st, err := autonomy.Open(ctx, home) + if err != nil { + return nil, "", gwconfig.Agent{}, err + } + return st, home, a, nil +} + +func gwPolicy(ctx context.Context, st *autonomy.Store, home, agent, action, document, hash string) (string, error) { + switch strings.ToLower(action) { + case "show": + p, ok, err := st.ApprovedPolicy(ctx, "primary") + if err != nil { + return "", err + } + if !ok { + return "no approved policy — consequential work is blocked. Stage one with action=stage, then approve it.", nil + } + return fmt.Sprintf("approved policy v%d hash=%s\n%s", p.Version, p.Hash, string(p.Document)), nil + case "stage": + var doc autonomy.DelegationPolicy + if err := json.Unmarshal([]byte(document), &doc); err != nil { + return "", fmt.Errorf("document is not valid DelegationPolicy JSON: %w", err) + } + canon, h, err := autonomy.CanonicalPolicy(doc) + if err != nil { + return "", err + } + ver, err := st.NextPolicyVersion(ctx, "primary") + if err != nil { + return "", err + } + if err := st.InsertPolicy(ctx, autonomy.Policy{ID: "policy-" + h[:8], ObjectiveID: "primary", Version: ver, Document: canon, Hash: h, Status: "draft"}); err != nil { + return "", err + } + // The canonical bytes are kept beside the agent so the exact document a + // human reviewed stays inspectable, keyed by the hash they approve. + _ = os.MkdirAll(filepath.Join(home, "policies"), 0o700) + _ = atomicfile.WriteFile(filepath.Join(home, "policies", h+".json"), canon, 0o600) + _ = autonomy.WriteConfigMirror(ctx, home, st) + return fmt.Sprintf("Draft policy v%d staged (hash %s). Show the user what it allows in plain language, then approve with gw_policy action=approve hash=%s.", ver, h[:12], h), nil + case "approve": + pols, err := st.ListPolicies(ctx, "primary") + if err != nil { + return "", err + } + var match string + for _, p := range pols { + if p.Hash == hash || strings.HasPrefix(p.Hash, hash) { + match = p.Hash + break + } + } + if match == "" { + return "", fmt.Errorf("no policy matching %q", hash) + } + if err := st.ApprovePolicy(ctx, match); err != nil { + return "", err + } + _ = autonomy.WriteConfigMirror(ctx, home, st) + return fmt.Sprintf("Approved policy %s for %s. It may now do consequential work within those bounds.", match[:12], agent), nil + } + return "", fmt.Errorf("action must be show, stage, or approve") +} + +func gwGrant(ctx context.Context, st *autonomy.Store, home, action, rtype, locator, mode, id string) (string, error) { + switch strings.ToLower(action) { + case "grant": + if rtype == "" { + rtype = "filesystem" // the common case is just a path + } + if mode == "" { + mode = "read" + } + if rtype == "filesystem" { + canon, err := autonomy.CanonicalFilesystemGrant(locator) + if err != nil { + return "", fmt.Errorf("cannot grant: %w", err) + } + locator = canon + } + rid := fmt.Sprintf("res-%s-%d", rtype, time.Now().UnixNano()) + if err := st.InsertResource(ctx, autonomy.Resource{ID: rid, ObjectiveID: "primary", Type: rtype, Locator: locator, AccessMode: mode, AuthorizationSource: "admin", Status: "active"}); err != nil { + return "", err + } + _ = autonomy.WriteConfigMirror(ctx, home, st) + return fmt.Sprintf("Granted %s %s (%s) as %s.", rtype, locator, mode, rid), nil + case "list": + res, err := st.ListResources(ctx, "primary") + if err != nil { + return "", err + } + if len(res) == 0 { + return "no resource grants — the agent can only reach its own home", nil + } + var b strings.Builder + for _, r := range res { + fmt.Fprintf(&b, "%s: %s %s (%s) [%s]\n", r.ID, r.Type, r.Locator, r.AccessMode, r.Status) + } + return b.String(), nil + case "revoke": + if err := st.SetResourceStatus(ctx, id, "revoked"); err != nil { + return "", err + } + _ = autonomy.WriteConfigMirror(ctx, home, st) + return "revoked " + id + " (effective at the next dispatch)", nil + } + return "", fmt.Errorf("action must be grant, list, or revoke") +} + +// gwWake runs one bounded wake on demand. Autonomy is NOT required here — +// being autonomous governs whether an agent wakes on its own, not whether a +// human may ask it to work now. +func gwWake(ctx context.Context, st *autonomy.Store, home, agent string, cfg gwconfig.Agent) (string, error) { + if strings.TrimSpace(cfg.Objective) == "" { + return "", fmt.Errorf("agent %q has no objective to advance — set one with gw_agent action=objective", agent) + } + if _, hasPol, err := st.ApprovedPolicy(ctx, "primary"); err != nil { + return "", err + } else if !hasPol { + return "blocked: no approved policy — stage and approve one first (gw_policy)", nil + } + provider.LoadDotEnv() + prov, err := provider.NewFromEnv() + if err != nil { + return "", fmt.Errorf("no model configured: %w", err) + } + ex := &autonomy.Executive{Store: st, Home: home, AgentID: agent, Objective: cfg.Objective, Runner: llm.NewRunner(prov)} + out, err := ex.RunOnce(ctx) + if err != nil { + return "", err + } + var b strings.Builder + fmt.Fprintf(&b, "run %s: %s\n", out.RunID, out.Status) + if out.Report != "" { + b.WriteString(out.Report + "\n") + } + if out.InteractionID != "" { + fmt.Fprintf(&b, "suspended on %s — answer with gw_answer\n", out.InteractionID) + } + return b.String(), nil +} + +func gwInbox(ctx context.Context, st *autonomy.Store, agent string) (string, error) { + inter, err := st.PendingInteractions(ctx, agent) + if err != nil { + return "", err + } + if len(inter) == 0 { + return "inbox empty — no pending questions", nil + } + var b strings.Builder + for _, in := range inter { + fmt.Fprintf(&b, "%s [%s] %s\n", in.ID, in.Kind, in.Question) + } + return b.String(), nil +} + +func gwAnswer(ctx context.Context, st *autonomy.Store, home, agent, id, answer string, cfg gwconfig.Agent) (string, error) { + in, ok, err := st.GetInteraction(ctx, id) + if err != nil || !ok { + return "", fmt.Errorf("no interaction %q", id) + } + if in.AgentID != agent { + return "", fmt.Errorf("interaction %q belongs to %s", id, in.AgentID) + } + if in.Status != "pending" { + return "", fmt.Errorf("interaction %q is not pending (already answered or cancelled) — answering again would re-run its side effects", id) + } + provider.LoadDotEnv() + prov, err := provider.NewFromEnv() + if err != nil { + return "", fmt.Errorf("no model configured: %w", err) + } + ex := &autonomy.Executive{Store: st, Home: home, AgentID: agent, Objective: cfg.Objective, Runner: llm.NewRunner(prov)} + // Resume FIRST, mark answered only after: a failed resume must stay + // retryable rather than swallowing the answer. + out, err := ex.ResumeSuspended(ctx, in, answer) + if err != nil { + return "", fmt.Errorf("resume failed (interaction still pending): %w", err) + } + if err := st.ResolveInteraction(ctx, id, answer); err != nil { + return "", err + } + return fmt.Sprintf("answered %s; run %s → %s. %s", id, in.RunID, out.Status, out.Report), nil +} + +func gwJournal(ctx context.Context, st *autonomy.Store) (string, error) { + runs, err := st.ListRuns(ctx, "primary", 10) + if err != nil { + return "", err + } + var b strings.Builder + fmt.Fprintf(&b, "runs (%d):\n", len(runs)) + for _, r := range runs { + fmt.Fprintf(&b, " %s [%s] %s\n", r.ID, r.Status, r.CreatedAt.Format(time.RFC3339)) + } + actions, _ := st.ListActions(ctx, "primary", 20) + fmt.Fprintf(&b, "actions (%d):\n", len(actions)) + for _, a := range actions { + fmt.Fprintf(&b, " %s %s %s → %s (policy %s)\n", a.CreatedAt.Format("15:04:05"), a.Kind, a.Target, a.Status, shortHash(a.PolicyHash)) + } + return b.String(), nil +} + +func gwDoctor(ctx context.Context, st *autonomy.Store, home, agent string, cfg gwconfig.Agent) (string, error) { + var b strings.Builder + check := func(label string, good bool, detail string) { + mark := "ok" + if !good { + mark = "FAIL" + } + fmt.Fprintf(&b, "[%s] %s: %s\n", mark, label, detail) + } + for _, d := range []string{"policies", "workspace/generated", "workspace/scratch", "runs", ".memcode/sessions"} { + _, err := os.Stat(filepath.Join(home, d)) + check("dir "+d, err == nil, filepath.Join(home, d)) + } + check("objective", cfg.Objective != "", orElse(cfg.Objective, "none — gw_agent action=objective")) + check("autonomous", cfg.Autonomous, map[bool]string{ + true: "may run unattended", + false: "on-demand only (gw_wake); gw_agent action=autonomous to change", + }[cfg.Autonomous]) + if cfg.Paused { + fmt.Fprintf(&b, "[info] paused: no unattended wakes will fire\n") + } + pol, hasPol, _ := st.ApprovedPolicy(ctx, "primary") + check("approved policy", hasPol, func() string { + if hasPol { + return fmt.Sprintf("v%d %s", pol.Version, shortHash(pol.Hash)) + } + return "none — consequential work blocked" + }()) + if _, err := autonomy.InitializeGeneratedWorkspace(home); err != nil { + check("generated workspace", false, err.Error()) + } else { + check("generated workspace", true, "git initialized") + } + fmt.Fprintf(&b, "[info] sandbox: %s\n", sandboxNote()) + if cfg.Browser == gwconfig.BrowserExistingChrome { + sock, err := broker.SocketPath() + reachable := err == nil && broker.NewClient(sock).Reachable() + check("existing-Chrome broker", reachable, orElse(map[bool]string{true: sock}[reachable], "not reachable — browser work will fail closed (gw_browser)")) + } + trigs, _ := st.ListTriggers(ctx) + pend, _ := st.PendingInteractions(ctx, agent) + fmt.Fprintf(&b, "self-scheduled wakes: %d, pending questions: %d\n", len(trigs), len(pend)) + return b.String(), nil +} + +// gwBrowser checks the prerequisites for driving the user's OWN Chrome and +// attempts a real, bounded connection. It cannot click Chrome's consent dialog +// — that is the user's step, by design. +func gwBrowser(ctx context.Context) (string, error) { + var b strings.Builder + ok := true + check := func(label string, good bool, detail string) { + mark := "ok" + if !good { + mark = "FAIL" + ok = false + } + fmt.Fprintf(&b, "[%s] %s: %s\n", mark, label, detail) + } + npx, err := exec.LookPath("npx") + check("npx available", err == nil, orElse(npx, "not found on PATH — Node.js is required")) + sock, err := broker.SocketPath() + if err != nil { + check("broker socket path", false, err.Error()) + } else { + reachable := broker.NewClient(sock).Reachable() + check("gateway browser broker", reachable, orElse(map[bool]string{true: sock}[reachable], "not reachable — start the gateway (memcode gateway run) first")) + } + if !ok { + b.WriteString("\nFix the above, then try again.") + return b.String(), nil + } + b.WriteString("\nAttempting a connection to the running Chrome (10s timeout)...\n") + cctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + mgr := mcpConnectChrome(cctx) + defer mgr.Close() + toolCount := len(mgr.Tools()) + if toolCount == 0 { + b.WriteString("[FAIL] could not connect to Chrome\n") + for _, e := range mgr.Errors() { + fmt.Fprintf(&b, " - %v\n", e) + } + b.WriteString("Tell the user to check: Chrome 144+, Remote Debugging toggled on at\n") + b.WriteString("chrome://inspect/#remote-debugging, Chrome actually running, and to click\n") + b.WriteString("Allow if a dialog appears — only they can do that last step.\n") + return b.String(), nil + } + fmt.Fprintf(&b, "[ok] connected — %d browser tool(s) available. Existing-Chrome work is ready.\n", toolCount) + return b.String(), nil +} + +func shortHash(h string) string { + if len(h) > 12 { + return h[:12] + } + return h +} + +func sandboxNote() string { + if autonomy.SandboxAvailable() { + return "hardened (bwrap)" + } + return "no bwrap — generated code runs fail-closed unless explicitly approved" +} + +func orElse(s, fallback string) string { + if strings.TrimSpace(s) == "" { + return fallback + } + return s +} + +// mcpConnectChrome starts chrome-devtools-mcp in --autoConnect mode, which +// attaches to an ALREADY-RUNNING Chrome rather than launching one. The version +// is pinned in internal/browser so the check here and the agent's real browser +// runs speak to the same server. +func mcpConnectChrome(ctx context.Context) *mcp.Manager { + return mcp.Connect(ctx, map[string]mcp.ServerConfig{ + "chrome-devtools": {Type: "stdio", Command: "npx", Args: []string{"-y", browser.ChromeDevToolsMCPPackage, "--autoConnect"}}, + }, mcp.Options{Version: "0.1.0"}) +} diff --git a/cmd/admin_autonomy_test.go b/cmd/admin_autonomy_test.go new file mode 100644 index 0000000..aa97830 --- /dev/null +++ b/cmd/admin_autonomy_test.go @@ -0,0 +1,280 @@ +package cmd + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/memcode-ai/memcode/internal/agent/tools" + gwconfig "github.com/memcode-ai/memcode/internal/gateway/config" +) + +// setupAgentHome isolates HOME/XDG_CONFIG_HOME so a test's agents never touch +// the real ~/.memcode or ~/.config/memcode. +func setupAgentHome(t *testing.T) string { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg")) + return home +} + +// admin runs one gw_* tool exactly as the admin cockpit would. This IS the +// interface — there is no CLI subcommand path and no second cockpit — so every +// test here goes through adminExecute. +func admin(t *testing.T, name string, in map[string]any) string { + t.Helper() + b, err := json.Marshal(in) + if err != nil { + t.Fatal(err) + } + out, err := adminExecute(context.Background(), name, b) + if err != nil { + t.Fatalf("%s(%v): %v", name, in, err) + } + return out +} + +func adminErr(t *testing.T, name string, in map[string]any) error { + t.Helper() + b, _ := json.Marshal(in) + _, err := adminExecute(context.Background(), name, b) + return err +} + +// Objective and autonomy are separate grants. Creating an agent with an +// objective must NOT make it autonomous — that was the original design mistake +// and the thing most likely to silently regress. +func TestObjectiveDoesNotImplyAutonomy(t *testing.T) { + setupAgentHome(t) + out := admin(t, tools.GwAgent, map[string]any{"action": "add", "name": "jobhunt", "objective": "Find backend roles"}) + if !strings.Contains(out, "NOT yet autonomous") { + t.Fatalf("add-with-objective should say autonomy is a separate grant: %q", out) + } + cfg, err := gwconfig.Load() + if err != nil { + t.Fatal(err) + } + a := cfg.Agents["jobhunt"] + if a.Objective == "" { + t.Fatal("objective not stored") + } + if a.Autonomous || a.Unattended() { + t.Fatalf("agent became autonomous from an objective alone: %+v", a) + } + + admin(t, tools.GwAgent, map[string]any{"action": "autonomous", "name": "jobhunt", "autonomous": "true"}) + cfg, _ = gwconfig.Load() + if !cfg.Agents["jobhunt"].Unattended() { + t.Fatal("explicit grant did not make the agent autonomous") + } + + // Anything but an explicit yes revokes — authority must not be granted by typo. + admin(t, tools.GwAgent, map[string]any{"action": "autonomous", "name": "jobhunt", "autonomous": "maybe"}) + cfg, _ = gwconfig.Load() + if cfg.Agents["jobhunt"].Autonomous { + t.Fatal("a non-affirmative value granted autonomy") + } +} + +// The other half of the orthogonality: unattended with no objective at all. +func TestAutonomousWithoutObjective(t *testing.T) { + setupAgentHome(t) + admin(t, tools.GwAgent, map[string]any{"action": "add", "name": "digest"}) + admin(t, tools.GwAgent, map[string]any{"action": "autonomous", "name": "digest", "autonomous": "true"}) + cfg, _ := gwconfig.Load() + a := cfg.Agents["digest"] + if !a.Unattended() { + t.Fatal("scheduled agent without an objective should still be governed as unattended") + } + if a.Objective != "" { + t.Fatal("objective invented") + } + // It has nothing to advance, so an on-demand wake is refused rather than + // making something up. + if err := adminErr(t, tools.GwWake, map[string]any{"agent": "digest"}); err == nil { + t.Fatal("wake without an objective should be refused") + } +} + +func TestAgentLifecycleAndPause(t *testing.T) { + home := setupAgentHome(t) + admin(t, tools.GwAgent, map[string]any{"action": "add", "name": "test-agent", "objective": "Maintain an outcome"}) + if _, err := os.Stat(filepath.Join(home, ".memcode", "agents", "test-agent")); err != nil { + // The home is created lazily on first store use, not at add time. + _ = err + } + admin(t, tools.GwAgent, map[string]any{"action": "pause", "name": "test-agent"}) + cfg, _ := gwconfig.Load() + if !cfg.Agents["test-agent"].Paused { + t.Fatal("pause not recorded") + } + admin(t, tools.GwAgent, map[string]any{"action": "resume", "name": "test-agent"}) + cfg, _ = gwconfig.Load() + if cfg.Agents["test-agent"].Paused { + t.Fatal("resume not recorded") + } + + out := admin(t, tools.GwOverview, nil) + if !strings.Contains(out, "test-agent") || !strings.Contains(out, "objective=") { + t.Fatalf("overview missing agent or objective: %q", out) + } + + admin(t, tools.GwAgent, map[string]any{"action": "remove", "name": "test-agent"}) + cfg, _ = gwconfig.Load() + if _, ok := cfg.Agents["test-agent"]; ok { + t.Fatal("agent not removed") + } +} + +func TestPolicyLifecycleBlocksThenApproves(t *testing.T) { + setupAgentHome(t) + admin(t, tools.GwAgent, map[string]any{"action": "add", "name": "pa", "objective": "Keep things tidy"}) + + // No policy yet → an on-demand wake is blocked before any model is built. + out := admin(t, tools.GwWake, map[string]any{"agent": "pa"}) + if !strings.Contains(out, "blocked") { + t.Fatalf("expected a blocked wake without a policy, got %q", out) + } + + policy := map[string]any{ + "objective_scope": "primary", "consequence_classes": []string{"observe", "local_mutation"}, + "max_seconds": 300, "max_actions_per_period": 8, "max_delegation_depth": 1, + } + pb, _ := json.Marshal(policy) + out = admin(t, tools.GwPolicy, map[string]any{"agent": "pa", "action": "stage", "document": string(pb)}) + if !strings.Contains(out, "Draft policy v1 staged") { + t.Fatalf("stage=%q", out) + } + agentHome, _ := gwconfig.AgentHome("pa") + entries, err := os.ReadDir(filepath.Join(agentHome, "policies")) + if err != nil || len(entries) != 1 { + t.Fatalf("policies dir: %v %v", entries, err) + } + hash := strings.TrimSuffix(entries[0].Name(), ".json") + + out = admin(t, tools.GwPolicy, map[string]any{"agent": "pa", "action": "approve", "hash": hash[:12]}) + if !strings.Contains(out, "Approved policy") { + t.Fatalf("approve=%q", out) + } + out = admin(t, tools.GwPolicy, map[string]any{"agent": "pa", "action": "show"}) + if !strings.Contains(out, "approved policy v1") { + t.Fatalf("show=%q", out) + } + mirrored, err := os.ReadFile(filepath.Join(agentHome, "config.yaml")) + if err != nil || !strings.Contains(string(mirrored), "approved: true") { + t.Fatalf("config.yaml mirror missing approval: %v %q", err, mirrored) + } +} + +func TestGrantAndRevoke(t *testing.T) { + home := setupAgentHome(t) + admin(t, tools.GwAgent, map[string]any{"action": "add", "name": "pa2", "objective": "Watch a folder"}) + grant := filepath.Join(home, "watch") + if err := os.MkdirAll(grant, 0o755); err != nil { + t.Fatal(err) + } + // No type, no mode — the common case is just a path. + out := admin(t, tools.GwGrant, map[string]any{"agent": "pa2", "action": "grant", "locator": grant}) + if !strings.Contains(out, "Granted filesystem") || !strings.Contains(out, "(read)") { + t.Fatalf("grant=%q", out) + } + out = admin(t, tools.GwGrant, map[string]any{"agent": "pa2", "action": "list"}) + var resID string + for _, line := range strings.Split(out, "\n") { + if i := strings.Index(line, ":"); i > 0 { + resID = line[:i] + break + } + } + if resID == "" { + t.Fatalf("no resource id in %q", out) + } + admin(t, tools.GwGrant, map[string]any{"agent": "pa2", "action": "revoke", "id": resID}) + out = admin(t, tools.GwGrant, map[string]any{"agent": "pa2", "action": "list"}) + if !strings.Contains(out, "[revoked]") { + t.Fatalf("expected revoked: %q", out) + } + agentHome, _ := gwconfig.AgentHome("pa2") + mirrored, err := os.ReadFile(filepath.Join(agentHome, "config.yaml")) + if err != nil || !strings.Contains(string(mirrored), "revoked") { + t.Fatalf("config.yaml mirror missing revoke: %v %q", err, mirrored) + } +} + +// An autonomous agent's recurring cadence is an ORDINARY schedule delivering to +// the agent itself — there is no second scheduler. +func TestScheduleDefaultsToAgentRouteForAutonomousAgent(t *testing.T) { + setupAgentHome(t) + admin(t, tools.GwAgent, map[string]any{"action": "add", "name": "waker", "objective": "do a thing"}) + admin(t, tools.GwAgent, map[string]any{"action": "autonomous", "name": "waker", "autonomous": "true"}) + admin(t, tools.GwSchedule, map[string]any{ + "action": "add", "name": "waker-cadence", "every": "6h", + "task": "Advance the objective.", "agent": "waker", + }) + cfg, _ := gwconfig.Load() + var found bool + for _, sc := range cfg.Schedules { + if sc.Name == "waker-cadence" { + found = true + if sc.DeliverTo != "agent:waker" { + t.Fatalf("deliver_to = %q, want agent:waker", sc.DeliverTo) + } + } + } + if !found { + t.Fatal("schedule not added") + } +} + +func TestDoctorAndInbox(t *testing.T) { + setupAgentHome(t) + admin(t, tools.GwAgent, map[string]any{"action": "add", "name": "cock", "objective": "Tidy my notes"}) + out := admin(t, tools.GwDoctor, map[string]any{"agent": "cock"}) + for _, want := range []string{"objective", "autonomous", "sandbox", "approved policy"} { + if !strings.Contains(out, want) { + t.Fatalf("doctor missing %q: %q", want, out) + } + } + out = admin(t, tools.GwInbox, map[string]any{"agent": "cock"}) + if !strings.Contains(out, "inbox empty") { + t.Fatalf("inbox=%q", out) + } +} + +// The fourth combination: an ordinary agent — no objective, not autonomous — +// stays exactly as it was. In particular it grows no autonomy store, so the +// governance machinery costs nothing until someone asks for it. +func TestOrdinaryAgentGetsNoAutonomyStore(t *testing.T) { + home := setupAgentHome(t) + admin(t, tools.GwAgent, map[string]any{"action": "add", "name": "plain"}) + admin(t, tools.GwOverview, nil) + + cfg, _ := gwconfig.Load() + a := cfg.Agents["plain"] + if a.Autonomous || a.Objective != "" || a.Paused { + t.Fatalf("ordinary agent picked up autonomy settings: %+v", a) + } + if _, err := os.Stat(filepath.Join(home, ".memcode", "agents", "plain", "agent.db")); !os.IsNotExist(err) { + t.Fatalf("ordinary agent grew an autonomy store (err=%v)", err) + } +} + +// An agent with an objective but WITHOUT autonomy still works on demand — the +// grant governs unprompted action, not whether a human may ask. +func TestObjectiveWithoutAutonomyStillWakesOnDemand(t *testing.T) { + setupAgentHome(t) + admin(t, tools.GwAgent, map[string]any{"action": "add", "name": "ondemand", "objective": "Tidy notes"}) + cfg, _ := gwconfig.Load() + if cfg.Agents["ondemand"].Autonomous { + t.Fatal("became autonomous") + } + // Reaches the policy gate rather than being refused for lacking autonomy. + out := admin(t, tools.GwWake, map[string]any{"agent": "ondemand"}) + if !strings.Contains(out, "blocked") || !strings.Contains(out, "policy") { + t.Fatalf("expected the policy gate, not an autonomy refusal: %q", out) + } +} diff --git a/cmd/admin_tools.go b/cmd/admin_tools.go index 80710dc..e342342 100644 --- a/cmd/admin_tools.go +++ b/cmd/admin_tools.go @@ -51,10 +51,59 @@ func adminExecute(ctx context.Context, name string, input json.RawMessage) (stri return "", err } return adminServiceAction(ctx, strings.ToLower(strings.TrimSpace(in.Action))) + case tools.GwBrowser: + return gwBrowser(ctx) // gateway-wide, not per-agent + case tools.GwPolicy, tools.GwGrant, tools.GwWake, tools.GwInbox, tools.GwAnswer, tools.GwJournal, tools.GwDoctor: + return adminAutonomy(ctx, name, input) } return "", fmt.Errorf("unknown admin tool %q", name) } +// adminAutonomy dispatches the per-agent autonomy tools. They all need the +// agent's store and its configuration, so opening those is done once here. +func adminAutonomy(ctx context.Context, name string, input json.RawMessage) (string, error) { + var in struct { + Agent string `json:"agent"` + Action string `json:"action"` + Document string `json:"document"` + Hash string `json:"hash"` + Type string `json:"type"` + Locator string `json:"locator"` + Mode string `json:"mode"` + ID string `json:"id"` + Answer string `json:"answer"` + } + if err := json.Unmarshal(input, &in); err != nil { + return "", err + } + agent := strings.TrimSpace(in.Agent) + if agent == "" { + return "", fmt.Errorf("an agent name is required") + } + st, home, cfg, err := agentStore(ctx, agent) + if err != nil { + return "", err + } + defer st.Close() + switch name { + case tools.GwPolicy: + return gwPolicy(ctx, st, home, agent, in.Action, in.Document, in.Hash) + case tools.GwGrant: + return gwGrant(ctx, st, home, in.Action, in.Type, in.Locator, in.Mode, in.ID) + case tools.GwWake: + return gwWake(ctx, st, home, agent, cfg) + case tools.GwInbox: + return gwInbox(ctx, st, agent) + case tools.GwAnswer: + return gwAnswer(ctx, st, home, agent, in.ID, in.Answer, cfg) + case tools.GwJournal: + return gwJournal(ctx, st) + case tools.GwDoctor: + return gwDoctor(ctx, st, home, agent, cfg) + } + return "", fmt.Errorf("unknown autonomy tool %q", name) +} + func adminOverview(ctx context.Context) (string, error) { settings, err := gwconfig.Load() if err != nil { @@ -130,6 +179,18 @@ func adminOverview(ctx context.Context) (string, error) { for _, name := range agentNames { a := settings.Agents[name] extra := "" + if a.Autonomous { + extra += " autonomous" + if a.Paused { + extra += "(paused)" + } + } + if a.Objective != "" { + extra += fmt.Sprintf(" objective=%q", trunc(a.Objective, 60)) + } + if a.Browser != "" { + extra += " browser=" + a.Browser + } if a.Model != "" { extra += " model=" + a.Model } @@ -390,6 +451,9 @@ func adminAgent(input json.RawMessage) (string, error) { Reasoning string `json:"reasoning"` Toolsets string `json:"toolsets"` DisabledToolsets string `json:"disabled_toolsets"` + Objective string `json:"objective"` + Autonomous string `json:"autonomous"` + Browser string `json:"browser"` } if err := json.Unmarshal(input, &in); err != nil { return "", err @@ -411,11 +475,89 @@ func adminAgent(input json.RawMessage) (string, error) { if r := strings.TrimSpace(in.Reasoning); r != "" && r != "off" && r != "medium" && r != "high" { return "", fmt.Errorf("reasoning must be off, medium, or high") } - settings.Agents[name] = gwconfig.Agent{Model: strings.TrimSpace(in.Model), Reasoning: strings.TrimSpace(in.Reasoning)} + if _, ok := settings.Agents[name]; ok { + return "", fmt.Errorf("agent %q already exists", name) + } + br, err := parseBrowser(in.Browser) + if err != nil { + return "", err + } + settings.Agents[name] = gwconfig.Agent{ + Model: strings.TrimSpace(in.Model), Reasoning: strings.TrimSpace(in.Reasoning), + Objective: strings.TrimSpace(in.Objective), Browser: br, + } if err := gwconfig.Save(settings); err != nil { return "", err } - return fmt.Sprintf("Created agent %s. Bind a channel to it with gw_channel field=agent; its identity lives at ~/.memcode/agents/%s/SOUL.md.", name, name), nil + msg := fmt.Sprintf("Created agent %s. Bind a channel to it with gw_channel field=agent; its identity lives at ~/.memcode/agents/%s/SOUL.md.", name, name) + if strings.TrimSpace(in.Objective) != "" { + // Deliberately NOT autonomous yet: holding an objective and being + // allowed to act on it unprompted are separate grants, and the second + // one deserves its own explicit confirmation. + msg = fmt.Sprintf("Created agent %s with an objective. It is NOT yet autonomous — it will only run when you ask (gw_wake). To let it run on its own: gw_agent action=autonomous, then approve a policy with gw_policy, then give it a cadence with gw_schedule.", name) + } + return msg, nil + case "objective": + p, ok := settings.Agents[name] + if !ok { + return "", fmt.Errorf("no agent %q", name) + } + p.Objective = strings.TrimSpace(in.Objective) + settings.Agents[name] = p + if err := gwconfig.Save(settings); err != nil { + return "", err + } + if p.Objective == "" { + return fmt.Sprintf("Cleared %s's objective; it stays an ordinary agent.", name), nil + } + return fmt.Sprintf("Objective for %s: %s", name, p.Objective), nil + case "autonomous": + p, ok := settings.Agents[name] + if !ok { + return "", fmt.Errorf("no agent %q", name) + } + on := isTrue(in.Autonomous) + p.Autonomous = on + settings.Agents[name] = p + if err := gwconfig.Save(settings); err != nil { + return "", err + } + if !on { + return fmt.Sprintf("%s will no longer run unattended. Scheduled wakes stop; it still answers on demand.", name), nil + } + return fmt.Sprintf("%s may now run unattended: every run is policy-gated, journals consequential actions, and suspends durably on a question instead of prompting. It still needs an approved policy (gw_policy) before it can do anything consequential.", name), nil + case "browser": + p, ok := settings.Agents[name] + if !ok { + return "", fmt.Errorf("no agent %q", name) + } + br, err := parseBrowser(in.Browser) + if err != nil { + return "", err + } + p.Browser = br + settings.Agents[name] = p + if err := gwconfig.Save(settings); err != nil { + return "", err + } + if br == gwconfig.BrowserExistingChrome { + return fmt.Sprintf("%s will drive your OWN running Chrome, inheriting your signed-in sessions. Check it works with gw_browser; if the broker isn't reachable, browser work fails closed rather than falling back to a logged-out profile.", name), nil + } + return fmt.Sprintf("%s uses a fresh, logged-out browser profile per run.", name), nil + case "pause", "resume": + p, ok := settings.Agents[name] + if !ok { + return "", fmt.Errorf("no agent %q", name) + } + p.Paused = action == "pause" + settings.Agents[name] = p + if err := gwconfig.Save(settings); err != nil { + return "", err + } + if p.Paused { + return fmt.Sprintf("%s paused — no further unattended wakes. Nothing deleted; resume any time.", name), nil + } + return fmt.Sprintf("%s resumed.", name), nil case "tools": p, ok := settings.Agents[name] if !ok { @@ -487,7 +629,30 @@ func adminAgent(input json.RawMessage) (string, error) { } return fmt.Sprintf("Removed agent %s. Its home under ~/.memcode/agents is kept; delete it yourself if you want the memory gone.", name), nil } - return "", fmt.Errorf("action must be add, tools, reasoning, model, or remove") + return "", fmt.Errorf("action must be add, objective, autonomous, browser, pause, resume, tools, reasoning, model, or remove") +} + +// parseBrowser validates the browser backend name, defaulting to ephemeral. +func parseBrowser(s string) (string, error) { + switch v := strings.TrimSpace(s); v { + case "", gwconfig.BrowserEphemeral: + return "", nil // empty == ephemeral; don't write the default into config + case gwconfig.BrowserExistingChrome: + return v, nil + default: + return "", fmt.Errorf("browser must be %s or %s", gwconfig.BrowserEphemeral, gwconfig.BrowserExistingChrome) + } +} + +// isTrue reads a boolean carried as a string through a tool call. Anything but +// an explicit yes is false — granting unattended authority must never happen by +// typo. +func isTrue(s string) bool { + switch strings.ToLower(strings.TrimSpace(s)) { + case "true", "yes", "on", "1": + return true + } + return false } func adminSchedule(input json.RawMessage) (string, error) { @@ -515,9 +680,20 @@ func adminSchedule(input json.RawMessage) (string, error) { } switch action { case "add": + // A schedule aimed at an agent with no explicit destination delivers to + // the agent itself: its report is journaled in its home rather than sent + // to a chat. This is what lets ONE scheduler drive both channel replies + // and unattended agent wakes, instead of a second cron implementation + // just for autonomous agents. + deliverTo := strings.TrimSpace(in.DeliverTo) + if deliverTo == "" && strings.TrimSpace(in.Agent) != "" { + if a, ok := settings.Agents[strings.TrimSpace(in.Agent)]; ok && a.Autonomous { + deliverTo = "agent:" + strings.TrimSpace(in.Agent) + } + } // The SAME validated construction the CLI uses (cron/every/at parsing, // deliver_to shape, duplicate names) — the surfaces cannot drift. - sc, err := gwconfig.BuildSchedule(name, in.Cron, in.Every, in.At, "", in.Task, in.DeliverTo, in.Agent, time.Now()) + sc, err := gwconfig.BuildSchedule(name, in.Cron, in.Every, in.At, "", in.Task, deliverTo, in.Agent, time.Now()) if err != nil { return "", err } diff --git a/cmd/personal_tools.go b/cmd/personal_tools.go new file mode 100644 index 0000000..4247cda --- /dev/null +++ b/cmd/personal_tools.go @@ -0,0 +1,5 @@ +package cmd + +// Personal cockpit tool definitions will live here as the conversational +// management surface is connected to the runtime. Keeping the file establishes +// the product boundary without exposing gateway administration machinery. diff --git a/cmd/run.go b/cmd/run.go index 8960a8b..71ddbb7 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -5,13 +5,17 @@ import ( "io" "os" "strings" + "time" "github.com/charmbracelet/x/term" "github.com/spf13/cobra" "github.com/memcode-ai/memcode/internal/agent/permissions" "github.com/memcode-ai/memcode/internal/agent/runtime" + "github.com/memcode-ai/memcode/internal/browser" + "github.com/memcode-ai/memcode/internal/browser/broker" "github.com/memcode-ai/memcode/internal/jobs" + "github.com/memcode-ai/memcode/internal/mcp" "github.com/memcode-ai/memcode/internal/provider" ) @@ -123,10 +127,46 @@ for local gateway development. Never store keys in .memcode.`, sess := runtime.New(st, runner, cfg.Root, model, mode, userOut()) sess.SetScoutModel(provider.EffectiveModel(cfg.Models.Explorer)) // cheap read-only scouts sess.SetNoContext(noContext) - if chrome { + browserSession, _ := cmd.Flags().GetString("browser-session") + if chrome && browserSession != "existing_chrome" { sess.SetBrowserEnabled(true) defer sess.CloseBrowser() // tear down Chrome when the one-shot session ends } + // --browser-session existing_chrome: this run is a autonomous agent's + // delegated worker that needs the USER'S OWN already-running, + // already-logged-in Chrome (Gmail, LinkedIn, an ATS, whatever the user + // is signed into) — NOT a fresh ephemeral profile with no session. It + // must acquire the gateway-owned broker's exclusive lease first; + // failing that, it fails closed. It must NEVER silently fall back to + // ephemeral Chrome — that would silently run the task logged out, + // which is not what was asked for and not what the policy authorized. + if browserSession == "existing_chrome" { + agentID, _ := cmd.Flags().GetString("browser-agent") + runID, _ := cmd.Flags().GetString("browser-run") + sock, err := broker.SocketPath() + if err != nil { + return fmt.Errorf("existing-Chrome unavailable (%w) — refusing to fall back to ephemeral Chrome", err) + } + client := broker.NewClient(sock) + lease, err := client.Acquire(agentID, runID, 10*time.Minute) + if err != nil { + return fmt.Errorf("existing-Chrome unavailable: %w — ask the user to run gw_browser in `memcode admin`; refusing to fall back to ephemeral Chrome", err) + } + defer client.Release(lease.Token) + sess.SetExtraMCPServers(map[string]mcp.ServerConfig{ + "chrome-devtools": {Type: "stdio", Command: "npx", Args: []string{"-y", browser.ChromeDevToolsMCPPackage, "--autoConnect"}}, + }) + } + // --allow-tools/--deny-tools: a delegated job's actual toolset restriction + // (see jobs.SpawnSpec.ToolPolicy). Applied here — before the --job branch — + // so it binds regardless of whether the child also carries --session. + allowTools, _ := cmd.Flags().GetString("allow-tools") + denyTools, _ := cmd.Flags().GetString("deny-tools") + if allowTools != "" || denyTools != "" { + if unknown := sess.SetToolPolicy(splitCSV(allowTools), splitCSV(denyTools)); len(unknown) > 0 { + fmt.Printf("note: tool policy entries not recognized (see memcode.ai/docs/agents/tools): %s\n", strings.Join(unknown, ", ")) + } + } // --job: this process IS a detached job's child. Serialize behind the // writer lock (one writer at a time) and record completion. @@ -228,6 +268,17 @@ for local gateway development. Never store keys in .memcode.`, }, } +// splitCSV parses a comma-separated flag value into trimmed, non-empty parts. +func splitCSV(s string) []string { + var out []string + for _, p := range strings.Split(s, ",") { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + return out +} + // resumeRef reads the session-resume intent from flags: --resume // wins; --continue/-c means "the most recent saved session"; "" = fresh. func resumeRef(cmd *cobra.Command) string { @@ -289,6 +340,16 @@ func init() { _ = runCmd.Flags().MarkHidden("tier") runCmd.Flags().Bool("report-back", false, "internal: persist the agent's final result so the caller can report it back") _ = runCmd.Flags().MarkHidden("report-back") + runCmd.Flags().String("allow-tools", "", "internal: comma-separated toolset/tool allow-list for a delegated job (empty = all)") + _ = runCmd.Flags().MarkHidden("allow-tools") + runCmd.Flags().String("deny-tools", "", "internal: comma-separated toolset/tool deny-list for a delegated job (deny wins)") + _ = runCmd.Flags().MarkHidden("deny-tools") + runCmd.Flags().String("browser-session", "", "internal: \"existing_chrome\" attaches this run to the user's own already-running Chrome via the gateway browser broker (fails closed, never falls back to ephemeral)") + _ = runCmd.Flags().MarkHidden("browser-session") + runCmd.Flags().String("browser-agent", "", "internal: agent id for the existing-Chrome broker lease") + _ = runCmd.Flags().MarkHidden("browser-agent") + runCmd.Flags().String("browser-run", "", "internal: run id for the existing-Chrome broker lease") + _ = runCmd.Flags().MarkHidden("browser-run") runCmd.Flags().String("protocol", "", "machine control protocol: stream-json (newline-delimited JSON on stdio, for SDK wrappers)") runCmd.Flags().BoolP("continue", "c", false, "resume the most recent session with its full conversation") runCmd.Flags().String("resume", "", "resume a session by id or prefix (see `memcode session recent`)") diff --git a/docs/autonomous-agents.md b/docs/autonomous-agents.md new file mode 100644 index 0000000..7d0841d --- /dev/null +++ b/docs/autonomous-agents.md @@ -0,0 +1,156 @@ +# Autonomous agents + +There is no separate kind of agent for this. An agent given a durable +**objective** and permission to run **autonomously** works on that objective +with nobody watching; everything else about it — its home, memory, skills, +model, toolsets — is the same agent you already had. + +You set this up by talking to `memcode admin`, the same cockpit that manages +channels, projects and schedules. There are no CLI subcommands to learn. + +## Two settings, deliberately separate + +| Setting | Question it answers | +|---|---| +| `objective` | What is this agent for? | +| `autonomous` | May it act on that without being asked? | +| `schedules` | When does it wake? | +| policy | What may it do while working? | +| `browser` | Which browser environment may it drive? | + +`objective` and `autonomous` are **separate grants**. Giving an agent a goal is +not the same act as letting it pursue that goal unsupervised, and all four +combinations are useful: + +| autonomous | objective | Behaviour | +|---|---|---| +| ✓ | ✓ | Works the objective on its own schedule. | +| ✓ | ✗ | Scheduled work under governance — a recurring task that is policy-gated, journaled, and can pause to ask you something. | +| ✗ | ✓ | A goal you work on together; it wakes only when you ask (`gw_wake`). | +| ✗ | ✗ | An ordinary conversational agent. | + +The second row matters: a plain scheduled agent used to run unattended with no +policy gate, no action journal, and no way to stop and ask. `autonomous: true` +is what turns those protections on, with or without an objective. + +## What it looks like in config + +`autonomous` never turns itself on — nothing here is implied by anything else. + +```yaml +agents: + jobhunt: + objective: "Find backend roles at Series B-D startups and keep a shortlist" + autonomous: true + browser: existing_chrome # the user's own signed-in Chrome + toolsets: [browser] +schedules: + - name: jobhunt-wake + every: 6h + agent: jobhunt # deliver_to defaults to the agent itself + task: "Advance the objective with one bounded step." +``` + +## Setting one up + +Run `memcode admin` and say what you want. It gathers what the agent will need, +proposes the whole thing in plain language — resources, policy, whether it runs +unattended, its cadence — and builds it once you approve. The tools it uses: + +| Tool | For | +|---|---| +| `gw_agent` | create; set objective, autonomous, browser; pause/resume; model, reasoning, toolsets | +| `gw_policy` | stage / show / approve the delegation policy | +| `gw_grant` | grant, list, revoke resources (a file, a directory, an MCP tool) | +| `gw_schedule` | recurring cadence (`agent=`, no `deliver_to`) | +| `gw_wake` | run one bounded wake now | +| `gw_inbox` / `gw_answer` | questions it is suspended on | +| `gw_journal` | recent runs and the consequential-action journal | +| `gw_doctor` | health check | +| `gw_browser` | verify access to the user's existing Chrome | + +## How a wake works + +- **Bounded.** Each wake is a single bounded loop, never a continuous process. + It ends by calling `report`, scheduling its next wake with `schedule_wake`, or + suspending with `ask_user`. +- **Policy-gated.** Consequential work requires an approved policy. A wake fails + closed *before* any model call if none is approved, or if it has expired or + been revoked. Approval is pinned by hash — an unattended agent cannot ask + permission mid-task, so the authority it will use is reviewed in advance. +- **Journaled.** Consequential actions are recorded reserve → running → + succeeded/failed with the policy hash, before dispatch. That journal is the + audit trail for work done while you weren't watching (`gw_journal`). +- **Confined.** `read_file`/`write_file` are limited to granted paths + (canonicalized, symlink-resolved); its own home and workspace are always + available. Revoking takes effect at the next dispatch. +- **Able to stop and ask.** `ask_user` suspends the run durably — the question + goes to `gw_inbox`, and the exact continuation (full transcript plus the + pending tool call) is saved. `gw_answer` resumes from precisely that point: + nothing already done is repeated, and a second answer is refused. +- **Able to delegate.** `delegate` spawns a scoped worker — a full memcode agent + with real toolsets (browser, MCP, shell, filesystem, skills) — as a detached + job, bounded by a subset of the parent's own policy. `check_delegate` collects + the result on a later wake. + +## Scheduling + +Two different things, one scheduler: + +- **Cadence you choose** is an ordinary `schedules:` entry (`gw_schedule`) with + `agent: `. Leave `deliver_to` empty and the wake goes to the agent + itself, its report journaled in its home rather than sent to a chat. +- **The agent's own next wake** ("come back in 45 minutes") is written from + inside a run by `schedule_wake`, stored per-agent and claimed atomically so it + cannot double-fire across restarts or across two gateway processes. + +A running gateway is what fires both. + +## Browser + +`browser: existing_chrome` attaches the agent's browser work to your **own +already-running, signed-in Chrome**, so it can act inside accounts you are +logged into. It needs Chrome 144+ with Remote Debugging enabled at +`chrome://inspect/#remote-debugging`, a running gateway (which owns the broker +arbitrating exclusive access), and your click on Chrome's own Allow dialog — +that consent step is yours alone. Check it with `gw_browser`. + +If the broker is unreachable, browser work **fails closed**. It never silently +falls back to a fresh logged-out profile, because that would quietly do +something other than what you asked. + +## Memory + +What an agent learns goes into `memory.md` in its home via the `remember` tool, +and is read back on every future wake — so an answer you give once is not asked +again. This is the same durable memory every memcode agent has. + +Known limitation: plain prose cannot distinguish *you told me this* from *I +inferred it* from *a website said so*, nor mark a claim safe to state on your +behalf, nor mark it stale. That matters once an agent fills in a form or sends +a message about you; structured provenance is a deliberate follow-up. + +## State and safety + +State lives under `~/.memcode/agents//`: `memory.md`, `config.yaml` (a +readable mirror of the policies and grants held in the database), `policies/`, +`runs/`, `workspace/`, and an SQLite store with WAL and versioned migrations. + +`pause` stops future unattended wakes without deleting anything. Removing an +agent from config keeps its home; deleting the home is a separate, explicit act. + +Generated code is untrusted: it runs with staged inputs, a scrubbed +environment, an executable allowlist, and bounded time/output, and fails closed +where a hardened sandbox (Linux `bwrap`) is unavailable. `gw_doctor` reports +sandbox availability. + +## Current scope + +Working: objective/subgoal store, policy gate, journaled bounded wakes, +resource grants, suspend/resume, delegation to scoped workers, self-scheduled +and gateway-scheduled wakes, the existing-Chrome broker, and health checks. + +Not yet wired: external-consequence classes beyond `external_effect` / +`external_representation` (financial, legal attestation, destructive) as live +dispatch inputs, adaptive pacing, and structured fact provenance. Native +desktop automation remains a future backend. diff --git a/docs/design/autonomous-agents.md b/docs/design/autonomous-agents.md new file mode 100644 index 0000000..1dfd7e1 --- /dev/null +++ b/docs/design/autonomous-agents.md @@ -0,0 +1,175 @@ +# Autonomous agents + +**Status:** Design contract +**Date:** August 30, 2026 + +> **Revised during implementation.** This was originally specified as "Personal +> Agents", a first-class agent type with its own cockpit (`memcode personal`), +> database, scheduler and tool registry. Review found that most of that +> duplicated infrastructure the ordinary agent system already had, and the two +> paths drifted. The capabilities below are unchanged; what changed is that they +> are now SETTINGS on the one Agent abstraction rather than a separate species, +> managed through `memcode admin`. Read "Personal Agent" below as "an agent with +> an objective, running autonomously". See `docs/autonomous-agents.md` for the +> shipped surface. +> +> One correction to the model itself: an objective and permission to pursue it +> unattended are ORTHOGONAL. `autonomous: true` gates governance (policy, +> journal, durable HITL) and applies with or without an objective — which is how +> a plain scheduled agent finally gets those protections too. + +## Purpose + +Autonomous agents are domain-general, long-lived environment agents configured +on any agent and operated through: + +```text +memcode admin +``` + +Such an agent accepts a user-authored objective, models relevant parts of the user's granted environment, creates and revises intermediate subgoals, schedules bounded future work, delegates dynamically scoped workers, pauses durably for human involvement, and improves its effectiveness through external generated artifacts. + +Memcode is the stable runtime kernel. Self-evolution occurs in the agent-owned capability layer, not by modifying the Memcode binary or source checkout. + +## Architectural invariant + +> `internal/agent/autonomy` contains no domain-specific workflow concepts, fixed worker roles, provider-specific business logic, or predefined user-profile schema. + +Domain behavior belongs in objective data, memory, generated artifacts, installed skills, resource grants, and available tools. + +## Product boundary + +**Personal is the cockpit; Gateway is the engine room.** + +An ordinary named agent is a durable identity with model, reasoning, memory, skills, and tool configuration. A Personal Agent is an additive named-agent kind that also owns objective state, policies, resources, triggers, action history, interactions, generated artifacts, and durable executive transcripts. + +The gateway remains the single daemon and recurring-execution engine. Personal Agents do not introduce a second service or identity hierarchy. + +## Objectives and subgoals + +A user objective is the durable statement of desired outcome and success criteria. It is authored or approved by the user and defines the executive's scope. + +A subgoal is agent-generated planning state beneath an objective. Subgoals may be created, revised, blocked, completed, or abandoned as evidence changes. They do not expand authority and are not substitutes for the objective's success criteria. + +Repository objectives remain repository-scoped and unchanged. Personal objectives are global, agent-scoped records stored beneath the named agent's home. + +## Agent home and ownership + +A Personal Agent owns state beneath: + +```text +~/.memcode/agents// + personal.db + policies/ + workspace/ + generated/ + scratch/ + runs/ + workers/ + .memcode/ + jobs/ + sessions/ +``` + +Existing identity, memory, and skill files remain in the same agent home. Removing an agent from gateway configuration is non-destructive. Deleting the home requires a separate explicit destructive operation. + +The SQLite store uses explicit migrations and WAL mode. It contains only domain-neutral records: objectives, subgoals, runs, triggers, policies, resources, facts, actions, generated items, and notifications. + +## Delegation policy + +Autonomy is governed by a canonical, versioned policy approved by hash. The policy describes objective scope, tools, resources, consequence classes, limits, budgets, pacing, escalation, notification, and stop conditions. + +General consequence classes are: + +```text +observe +local_mutation +external_effect +external_representation +financial +legal_attestation +destructive +``` + +Actions within an approved policy may proceed without repeated approval. Authority expansion requires approval of a new policy version. Restriction-only changes and revocation take effect immediately. Personal policy is an additional gate and never replaces Memcode's existing permission checks. + +## Resource grants + +Resources are opaque, typed grants with canonical locators, access modes, constraints, authorization provenance, policy version, and expiration or revocation state. Types may include filesystem locations, browser sessions or origins, MCP capabilities, commands, repositories, cloud tools, documents, communication channels, and generated processes. + +Agents begin with their own home and explicitly enabled tools. Access outside the home requires a grant. Sensitive contents, browser credentials, cookies, and ambient secrets are never exported as resources. + +## Dynamic execution envelopes + +Every direct or delegated run receives a structured execution envelope identifying its objective, subgoal, parent run, policy hash, selected tools, narrowed resources, allowed consequences, budgets, browser mode, and reporting behavior. + +A worker receives a strict subset of its parent's authority. Worker names and task descriptions are arbitrary data selected for the current subgoal; there are no compiled worker-role categories. Generated artifacts cannot increase their own envelope. + +## Durable interaction and continuation + +Generic interaction kinds are: + +```text +question +approval +environment_handoff +challenge +missing_information +policy_exception +``` + +An interaction records the run, job, session, conversation, pending tool-use ID, structured request, policy version, lifecycle timestamps, response, and continuation metadata. + +When a tool requires human involvement, the runtime persists the complete assistant response and unresolved tool-use block, creates the interaction, marks the run waiting, and exits cleanly. On answer, the runtime appends the matching tool result—or executes the exact approved saved call once—and resumes the same transcript without an extra user turn or replay of completed work. + +Suspending tool calls must initially be the sole tool use in an assistant response. Stale, duplicate, mismatched, expired, or resolved interactions fail closed. + +## Action journal and idempotency + +Every Personal Agent action is journaled through: + +```text +planned → reserved → running → succeeded | failed | uncertain | cancelled +``` + +The record contains objective, subgoal, run, kind, target, consequence class, policy hash, redacted request, idempotency data, result, evidence, and timestamps. + +Consequential actions are policy-checked and reserved before dispatch. Ambiguous outcomes become `uncertain` and are not automatically retried. Restart recovery must reconcile uncertainty through observation or human input. + +## Generated workspace and self-evolution + +The generated workspace is a permissive local Git repository, not a mandatory package format. It may contain scripts, compiled programs, browser procedures, transforms, evaluators, data stores, skills, MCP servers, managed services, documentation, and operating procedures. + +A lightweight database index records path, hash, purpose, provenance, parent revision, required envelope, invocation/evaluation commands, evaluation results, use time, and active revision. + +After meaningful work the executive evaluates progress, cost, latency, repeated steps, failures, corrections, instability, and reuse opportunities. It may continue, change strategy, reuse, generate, improve, retire, escalate, or abandon. Repeated autonomous use requires evaluation, a Git commit, policy compatibility, and rollback after regression. + +## Browser broker trust boundary + +Ordinary sessions retain the existing ephemeral browser backend. An agent configured with `browser: existing_chrome` may use an explicitly authorized connection to the user's existing Chrome through a gateway-owned broker and permission-protected local socket. + +The broker owns controller lifecycle, authenticates short-lived scoped run tokens, serializes control with leases, associates created pages with an agent and run, redacts sensitive headers, and exposes narrow operations rather than raw controller access. It never exports cookies or credentials and never closes or mutates unrelated tabs. + +Existing-Chrome access is broadly privileged. Policy and tab ownership reduce accidental interference but cannot make a compromised controller harmless. Connection, version, or authentication failures fail closed; they never silently fall back to another profile. + +Login and environmental challenges create durable handoff interactions tied to an owned tab. + +## Adaptive pacing + +Pacing considers urgency, deadlines, recent volume, repeated actions, concurrency, errors, warnings, challenges, uncertainty, quiet hours, and opportunities to batch locally. Persisted controls include resource concurrency, burst caps, cooldowns, bounded jitter, exponential backoff, warning-triggered slowdown, challenge suspension, and time-period budgets. + +Pacing exists for safe, low-impact operation—not human simulation or protection bypass. + +## Pause, revocation, and shutdown + +Pause prevents future wakes and consequential dispatch. Stop also requests active workers and generated services to terminate. Revocation is checked before every dispatch and releases affected resource and browser leases. + +Pending interactions may be cancelled. Uncertain actions require explicit reconciliation. Gateway restart recovery reconciles workers, interactions, triggers, sessions, browser leases, actions, services, and policy hashes before work resumes. + +No consequential recovered work may continue unless its recorded policy hash remains approved. + +Deletion is explicitly destructive and separate from non-destructive removal from gateway configuration. Audit export is redacted by default. + +## Stable-kernel boundary + +Personal Agents may create and operate external capabilities within approved envelopes, but they do not autonomously modify the Memcode executable or source checkout. New environment backends, including native desktop control, may be added later without introducing objective-specific concepts into the Personal core. diff --git a/docs/gateway/README.md b/docs/gateway/README.md index d541137..a46fbc9 100644 --- a/docs/gateway/README.md +++ b/docs/gateway/README.md @@ -95,7 +95,10 @@ projects: # written by `memcode project add` enabled: true default_project: memcode agents: # durable agents; identity + state in ~/.memcode/agents/ - personal: + jobhunt: + objective: "Find backend roles and keep a shortlist" # what it works toward + autonomous: true # ...and may work on it unprompted (separate grant) + browser: existing_chrome # drive the user's own signed-in Chrome model: claude-haiku-4-5 # omit model to let routing pick per task coder: model: claude-sonnet-5 @@ -134,6 +137,20 @@ project itself provides. A channel binds to a agent with `channels..agent` and a conversation switches with `/agent `. Each agent gets its own session transcript per conversation. +`objective` and `autonomous` turn an ordinary agent into one that works on its +own. They are SEPARATE grants: an objective says what the agent is for, +`autonomous: true` says it may act on that without being asked, and either is +useful without the other. An unattended run is policy-gated, journals its +consequential actions, and suspends durably rather than prompting a human who +is not there. `browser: existing_chrome` points its browser work at the user's +own signed-in Chrome instead of a fresh logged-out profile; `paused: true` +stops future unattended wakes without deleting anything. + +Its policy, resource grants, and run state live in the agent home rather than +`gateway.yaml`. Manage all of it by conversation in `memcode admin`. Removing +the configuration entry does not delete the home. See +`docs/autonomous-agents.md`. + ## Authorization and triggering Two independent checks gate a chat message, matching what Hermes and OpenClaw do: diff --git a/internal/agent/autonomy/action.go b/internal/agent/autonomy/action.go new file mode 100644 index 0000000..6234af6 --- /dev/null +++ b/internal/agent/autonomy/action.go @@ -0,0 +1,119 @@ +package autonomy + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "strings" + "time" +) + +type ActionStatus string + +const ( + ActionPlanned ActionStatus = "planned" + ActionReserved ActionStatus = "reserved" + ActionRunning ActionStatus = "running" + ActionSucceeded ActionStatus = "succeeded" + ActionFailed ActionStatus = "failed" + ActionUncertain ActionStatus = "uncertain" + ActionCancelled ActionStatus = "cancelled" +) + +type ActionIntent struct { + ID, ObjectiveID, SubgoalID, RunID, Kind, Target string + Consequence ConsequenceClass + PolicyHash string + Request json.RawMessage + IdempotencyKey string +} + +func RedactActionRequest(v json.RawMessage) json.RawMessage { + var x any + if json.Unmarshal(v, &x) != nil { + return json.RawMessage(`"[redacted]"`) + } + redactValue(x) + b, _ := json.Marshal(x) + return b +} +func redactValue(v any) { + m, ok := v.(map[string]any) + if !ok { + return + } + for k, val := range m { + lower := strings.ToLower(k) + if strings.Contains(lower, "token") || strings.Contains(lower, "password") || strings.Contains(lower, "secret") || strings.Contains(lower, "cookie") || strings.Contains(lower, "authorization") { + m[k] = "[redacted]" + } else { + redactValue(val) + } + } +} +func (s *Store) ReserveAction(ctx context.Context, a ActionIntent) (Action, bool, error) { + now := time.Now().UTC() + request := RedactActionRequest(a.Request) + res, err := s.db.ExecContext(ctx, `INSERT OR IGNORE INTO actions(id,objective_id,subgoal_id,run_id,kind,target,consequence_class,policy_hash,request_json,idempotency_key,status,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`, a.ID, a.ObjectiveID, a.SubgoalID, a.RunID, a.Kind, a.Target, a.Consequence, a.PolicyHash, string(request), nullableString(a.IdempotencyKey), ActionReserved, stamp(now), stamp(now)) + if err != nil { + return Action{}, false, err + } + n, _ := res.RowsAffected() + if n == 0 && a.IdempotencyKey != "" { + var existing Action + err = s.db.QueryRowContext(ctx, `SELECT id,status FROM actions WHERE objective_id=? AND idempotency_key=?`, a.ObjectiveID, a.IdempotencyKey).Scan(&existing.ID, &existing.Status) + return existing, false, err + } + return Action{ID: a.ID, ObjectiveID: a.ObjectiveID, Status: string(ActionReserved)}, n == 1, nil +} +func (s *Store) CompleteAction(ctx context.Context, id string, status ActionStatus, result, evidence json.RawMessage) error { + if status != ActionSucceeded && status != ActionFailed && status != ActionUncertain && status != ActionCancelled { + return fmt.Errorf("invalid terminal action status %q", status) + } + res, err := s.db.ExecContext(ctx, `UPDATE actions SET status=?,result_json=?,evidence_json=?,updated_at=? WHERE id=? AND status IN ('reserved','running')`, status, string(result), string(evidence), stamp(time.Now().UTC()), id) + if err != nil { + return err + } + n, _ := res.RowsAffected() + if n != 1 { + return fmt.Errorf("action %q is not reservable/running", id) + } + return nil +} + +// LinkActionJob records which detached job an action spawned, so a later wake +// can find its way back from a job id to the action it must close out. +func (s *Store) LinkActionJob(ctx context.Context, actionID, jobID string) error { + _, err := s.db.ExecContext(ctx, `UPDATE actions SET job_id=?,updated_at=? WHERE id=?`, jobID, stamp(time.Now().UTC()), actionID) + return err +} + +// ActionForJob returns the id of the action that spawned jobID, or "" when +// there is none. +func (s *Store) ActionForJob(ctx context.Context, jobID string) (string, error) { + var id string + err := s.db.QueryRowContext(ctx, `SELECT id FROM actions WHERE job_id=?`, jobID).Scan(&id) + if err == sql.ErrNoRows { + return "", nil + } + return id, err +} + +func (s *Store) MarkActionRunning(ctx context.Context, id string) error { + res, err := s.db.ExecContext(ctx, `UPDATE actions SET status='running',updated_at=? WHERE id=? AND status='reserved'`, stamp(time.Now().UTC()), id) + if err != nil { + return err + } + n, _ := res.RowsAffected() + if n != 1 { + return sql.ErrNoRows + } + return nil +} +func nullableString(s string) any { + if s == "" { + return nil + } + return s +} diff --git a/internal/agent/autonomy/action_test.go b/internal/agent/autonomy/action_test.go new file mode 100644 index 0000000..ce6a61d --- /dev/null +++ b/internal/agent/autonomy/action_test.go @@ -0,0 +1,44 @@ +package autonomy + +import ( + "context" + "encoding/json" + "strings" + "testing" +) + +func TestActionReservationIdempotencyUncertaintyAndRedaction(t *testing.T) { + ctx := context.Background() + s, err := Open(ctx, t.TempDir()) + if err != nil { + t.Fatal(err) + } + defer s.Close() + intent := ActionIntent{ID: "a1", ObjectiveID: "o1", Kind: "external.call", Consequence: ExternalEffect, PolicyHash: "h", Request: json.RawMessage(`{"token":"burn-me-not","nested":{"password":"hide"},"safe":"ok"}`), IdempotencyKey: "key1"} + got, fresh, err := s.ReserveAction(ctx, intent) + if err != nil || !fresh || got.Status != string(ActionReserved) { + t.Fatalf("action=%+v fresh=%v err=%v", got, fresh, err) + } + intent.ID = "a2" + existing, fresh, err := s.ReserveAction(ctx, intent) + if err != nil || fresh || existing.ID != "a1" { + t.Fatalf("existing=%+v fresh=%v err=%v", existing, fresh, err) + } + var request string + if err := s.db.QueryRowContext(ctx, `SELECT request_json FROM actions WHERE id='a1'`).Scan(&request); err != nil { + t.Fatal(err) + } + if strings.Contains(request, "burn-me-not") || strings.Contains(request, "hide") || !strings.Contains(request, "[redacted]") { + t.Fatalf("request not redacted: %s", request) + } + if err := s.MarkActionRunning(ctx, "a1"); err != nil { + t.Fatal(err) + } + if err := s.CompleteAction(ctx, "a1", ActionUncertain, json.RawMessage(`{"state":"unknown"}`), nil); err != nil { + t.Fatal(err) + } + var status string + if err := s.db.QueryRowContext(ctx, `SELECT status FROM actions WHERE id='a1'`).Scan(&status); err != nil || status != string(ActionUncertain) { + t.Fatalf("status=%q err=%v", status, err) + } +} diff --git a/internal/agent/autonomy/crud.go b/internal/agent/autonomy/crud.go new file mode 100644 index 0000000..578bd84 --- /dev/null +++ b/internal/agent/autonomy/crud.go @@ -0,0 +1,301 @@ +package autonomy + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "time" +) + +func jsonOr(raw json.RawMessage, fallback string) string { + if len(raw) == 0 { + return fallback + } + return string(raw) +} + +func nullStr(s string) any { + if s == "" { + return nil + } + return s +} + +// --- Subgoals --- + +func (s *Store) UpsertSubgoal(ctx context.Context, g Subgoal) error { + now := time.Now().UTC() + if g.Status == "" { + g.Status = "pending" + } + _, err := s.db.ExecContext(ctx, `INSERT INTO subgoals(id,objective_id,parent_id,description,status,priority,rationale,dependencies_json,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET description=excluded.description,status=excluded.status,priority=excluded.priority,rationale=excluded.rationale,updated_at=excluded.updated_at`, + g.ID, g.ObjectiveID, nullStr(g.ParentID), g.Description, g.Status, g.Priority, g.Rationale, jsonOr(g.Dependencies, "[]"), stamp(now), stamp(now)) + return err +} + +func (s *Store) ListSubgoals(ctx context.Context, objectiveID string) ([]Subgoal, error) { + rows, err := s.db.QueryContext(ctx, `SELECT id,objective_id,COALESCE(parent_id,''),description,status,priority,rationale,dependencies_json,created_at,updated_at FROM subgoals WHERE objective_id=? ORDER BY priority DESC, created_at`, objectiveID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Subgoal + for rows.Next() { + var g Subgoal + var created, updated, deps string + if err := rows.Scan(&g.ID, &g.ObjectiveID, &g.ParentID, &g.Description, &g.Status, &g.Priority, &g.Rationale, &deps, &created, &updated); err != nil { + return nil, err + } + g.Dependencies = json.RawMessage(deps) + g.CreatedAt, _ = time.Parse(time.RFC3339Nano, created) + g.UpdatedAt, _ = time.Parse(time.RFC3339Nano, updated) + out = append(out, g) + } + return out, rows.Err() +} + +func (s *Store) SetSubgoalStatus(ctx context.Context, id, status string) error { + res, err := s.db.ExecContext(ctx, `UPDATE subgoals SET status=?,updated_at=? WHERE id=?`, status, stamp(time.Now().UTC()), id) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return fmt.Errorf("subgoal %q not found", id) + } + return nil +} + +// --- Runs --- + +func (s *Store) CreateRun(ctx context.Context, r Run) error { + now := time.Now().UTC() + if r.CreatedAt.IsZero() { + r.CreatedAt = now + } + r.UpdatedAt = now + _, err := s.db.ExecContext(ctx, `INSERT INTO runs(id,objective_id,subgoal_id,parent_run_id,session_id,envelope_json,status,outcome_json,evidence_json,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)`, + r.ID, r.ObjectiveID, nullStr(r.SubgoalID), nullStr(r.ParentRunID), nullStr(r.SessionID), jsonOr(r.Envelope, "{}"), r.Status, string(r.Outcome), string(r.Evidence), stamp(r.CreatedAt), stamp(r.UpdatedAt)) + return err +} + +func (s *Store) UpdateRunStatus(ctx context.Context, id, status string, outcome json.RawMessage) error { + res, err := s.db.ExecContext(ctx, `UPDATE runs SET status=?,outcome_json=?,updated_at=? WHERE id=?`, status, string(outcome), stamp(time.Now().UTC()), id) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return fmt.Errorf("run %q not found", id) + } + return nil +} + +func (s *Store) ListRuns(ctx context.Context, objectiveID string, limit int) ([]Run, error) { + if limit <= 0 { + limit = 20 + } + rows, err := s.db.QueryContext(ctx, `SELECT id,objective_id,COALESCE(subgoal_id,''),COALESCE(parent_run_id,''),COALESCE(session_id,''),envelope_json,status,COALESCE(outcome_json,''),COALESCE(evidence_json,''),created_at,updated_at FROM runs WHERE objective_id=? ORDER BY created_at DESC LIMIT ?`, objectiveID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Run + for rows.Next() { + var r Run + var env, outcome, evidence, created, updated string + if err := rows.Scan(&r.ID, &r.ObjectiveID, &r.SubgoalID, &r.ParentRunID, &r.SessionID, &env, &r.Status, &outcome, &evidence, &created, &updated); err != nil { + return nil, err + } + r.Envelope, r.Outcome, r.Evidence = json.RawMessage(env), json.RawMessage(outcome), json.RawMessage(evidence) + r.CreatedAt, _ = time.Parse(time.RFC3339Nano, created) + r.UpdatedAt, _ = time.Parse(time.RFC3339Nano, updated) + out = append(out, r) + } + return out, rows.Err() +} + +// --- Policies --- + +func (s *Store) InsertPolicy(ctx context.Context, p Policy) error { + now := time.Now().UTC() + if p.CreatedAt.IsZero() { + p.CreatedAt = now + } + if p.Status == "" { + p.Status = "draft" + } + _, err := s.db.ExecContext(ctx, `INSERT INTO policies(id,objective_id,version,document_json,hash,status,approved_at,created_at) VALUES(?,?,?,?,?,?,?,?)`, + p.ID, p.ObjectiveID, p.Version, string(p.Document), p.Hash, p.Status, formatTimePtr(p.ApprovedAt), stamp(p.CreatedAt)) + return err +} + +func (s *Store) ApprovePolicy(ctx context.Context, hash string) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + now := stamp(time.Now().UTC()) + var objectiveID string + if err := tx.QueryRowContext(ctx, `SELECT objective_id FROM policies WHERE hash=?`, hash).Scan(&objectiveID); err != nil { + return fmt.Errorf("policy %q not found", hash) + } + if _, err := tx.ExecContext(ctx, `UPDATE policies SET status='superseded' WHERE objective_id=? AND status='approved'`, objectiveID); err != nil { + return err + } + res, err := tx.ExecContext(ctx, `UPDATE policies SET status='approved',approved_at=? WHERE hash=? AND status='draft'`, now, hash) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n != 1 { + return fmt.Errorf("policy %q is not a draft (already approved or unknown)", hash) + } + return tx.Commit() +} + +func (s *Store) ApprovedPolicy(ctx context.Context, objectiveID string) (Policy, bool, error) { + var p Policy + var doc, created string + var approved sql.NullString + err := s.db.QueryRowContext(ctx, `SELECT id,objective_id,version,document_json,hash,status,approved_at,created_at FROM policies WHERE objective_id=? AND status='approved'`, objectiveID). + Scan(&p.ID, &p.ObjectiveID, &p.Version, &doc, &p.Hash, &p.Status, &approved, &created) + if err == sql.ErrNoRows { + return Policy{}, false, nil + } + if err != nil { + return Policy{}, false, err + } + p.Document = json.RawMessage(doc) + p.CreatedAt, _ = time.Parse(time.RFC3339Nano, created) + if approved.Valid { + t, _ := time.Parse(time.RFC3339Nano, approved.String) + p.ApprovedAt = &t + } + return p, true, nil +} + +func (s *Store) ListPolicies(ctx context.Context, objectiveID string) ([]Policy, error) { + rows, err := s.db.QueryContext(ctx, `SELECT id,objective_id,version,document_json,hash,status,approved_at,created_at FROM policies WHERE objective_id=? ORDER BY version`, objectiveID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Policy + for rows.Next() { + var p Policy + var doc, created string + var approved sql.NullString + if err := rows.Scan(&p.ID, &p.ObjectiveID, &p.Version, &doc, &p.Hash, &p.Status, &approved, &created); err != nil { + return nil, err + } + p.Document = json.RawMessage(doc) + p.CreatedAt, _ = time.Parse(time.RFC3339Nano, created) + if approved.Valid { + t, _ := time.Parse(time.RFC3339Nano, approved.String) + p.ApprovedAt = &t + } + out = append(out, p) + } + return out, rows.Err() +} + +func (s *Store) NextPolicyVersion(ctx context.Context, objectiveID string) (int, error) { + var v int + err := s.db.QueryRowContext(ctx, `SELECT COALESCE(MAX(version),0)+1 FROM policies WHERE objective_id=?`, objectiveID).Scan(&v) + return v, err +} + +func formatTimePtr(t *time.Time) any { + if t == nil { + return nil + } + return stamp(*t) +} + +// --- Resources --- + +func (s *Store) InsertResource(ctx context.Context, r Resource) error { + now := time.Now().UTC() + if r.Status == "" { + r.Status = "active" + } + _, err := s.db.ExecContext(ctx, `INSERT INTO resources(id,objective_id,type,locator,access_mode,constraints_json,authorization_source,policy_hash,expires_at,status,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`, + r.ID, r.ObjectiveID, r.Type, r.Locator, r.AccessMode, jsonOr(r.Constraints, "{}"), r.AuthorizationSource, r.PolicyHash, formatTimePtr(r.ExpiresAt), r.Status, stamp(now), stamp(now)) + return err +} + +func (s *Store) ListResources(ctx context.Context, objectiveID string) ([]Resource, error) { + rows, err := s.db.QueryContext(ctx, `SELECT id,objective_id,type,locator,access_mode,constraints_json,authorization_source,policy_hash,expires_at,status,created_at,updated_at FROM resources WHERE objective_id=? ORDER BY created_at`, objectiveID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Resource + for rows.Next() { + var r Resource + var cons, created, updated string + var expires sql.NullString + if err := rows.Scan(&r.ID, &r.ObjectiveID, &r.Type, &r.Locator, &r.AccessMode, &cons, &r.AuthorizationSource, &r.PolicyHash, &expires, &r.Status, &created, &updated); err != nil { + return nil, err + } + r.Constraints = json.RawMessage(cons) + if expires.Valid { + t, _ := time.Parse(time.RFC3339Nano, expires.String) + r.ExpiresAt = &t + } + r.CreatedAt, _ = time.Parse(time.RFC3339Nano, created) + r.UpdatedAt, _ = time.Parse(time.RFC3339Nano, updated) + out = append(out, r) + } + return out, rows.Err() +} + +func (s *Store) SetResourceStatus(ctx context.Context, id, status string) error { + res, err := s.db.ExecContext(ctx, `UPDATE resources SET status=?,updated_at=? WHERE id=?`, status, stamp(time.Now().UTC()), id) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return fmt.Errorf("resource %q not found", id) + } + return nil +} + +// --- Actions (list) --- + +func (s *Store) ListActions(ctx context.Context, objectiveID string, limit int) ([]Action, error) { + if limit <= 0 { + limit = 50 + } + rows, err := s.db.QueryContext(ctx, `SELECT id,objective_id,COALESCE(subgoal_id,''),COALESCE(run_id,''),kind,target,consequence_class,policy_hash,request_json,COALESCE(idempotency_key,''),status,COALESCE(result_json,''),COALESCE(evidence_json,''),COALESCE(job_id,''),created_at,updated_at FROM actions WHERE objective_id=? ORDER BY created_at DESC LIMIT ?`, objectiveID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Action + for rows.Next() { + var a Action + var req, result, evidence, created, updated string + if err := rows.Scan(&a.ID, &a.ObjectiveID, &a.SubgoalID, &a.RunID, &a.Kind, &a.Target, &a.ConsequenceClass, &a.PolicyHash, &req, &a.IdempotencyKey, &a.Status, &result, &evidence, &a.JobID, &created, &updated); err != nil { + return nil, err + } + a.Request, a.Result, a.Evidence = json.RawMessage(req), json.RawMessage(result), json.RawMessage(evidence) + a.CreatedAt, _ = time.Parse(time.RFC3339Nano, created) + a.UpdatedAt, _ = time.Parse(time.RFC3339Nano, updated) + out = append(out, a) + } + return out, rows.Err() +} + +// --- Notifications --- + +func (s *Store) InsertNotification(ctx context.Context, n Notification) error { + now := time.Now().UTC() + if n.Status == "" { + n.Status = "pending" + } + _, err := s.db.ExecContext(ctx, `INSERT INTO notifications(id,objective_id,kind,payload_json,status,created_at,updated_at) VALUES(?,?,?,?,?,?,?)`, + n.ID, n.ObjectiveID, n.Kind, jsonOr(n.Payload, "{}"), n.Status, stamp(now), stamp(now)) + return err +} diff --git a/internal/agent/autonomy/delegation.go b/internal/agent/autonomy/delegation.go new file mode 100644 index 0000000..ab36f39 --- /dev/null +++ b/internal/agent/autonomy/delegation.go @@ -0,0 +1,73 @@ +package autonomy + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/memcode-ai/memcode/internal/atomicfile" + "github.com/memcode-ai/memcode/internal/jobs" +) + +type ExecutionEnvelope struct { + Task, ExpectedOutput, CompletionCondition string + Context json.RawMessage + Toolsets []string + Resources []string + Consequences []ConsequenceClass + Deadline string + Budgets jobs.ExecutionBudgets + ParentRunID, SubgoalID string + AllowDelegation bool + DelegationDepth int + // BrowserSession selects the worker's browser backend when Toolsets + // includes "browser": BrowserExistingChrome (the default for Personal + // Agent delegation — the user's own already-running, already-logged-in + // Chrome, reached through the gateway-owned broker) or BrowserEphemeral + // (a fresh, logged-out profile — explicit opt-down only). See + // docs/design/personal-agents.md "Browser broker trust boundary". + BrowserSession string +} + +const ( + BrowserExistingChrome = "existing_chrome" + BrowserEphemeral = "ephemeral" +) + +func ValidateDelegation(parent DelegationPolicy, e ExecutionEnvelope) error { + if e.Task == "" || e.CompletionCondition == "" { + return fmt.Errorf("worker task and completion condition are required") + } + if e.DelegationDepth > parent.MaxDelegationDepth { + return fmt.Errorf("delegation depth exceeds policy") + } + // An empty parent.AllowedTools means "no restriction by name" — the same + // convention Executive.allowedTools uses (restrictByName := len(...) > 0). + // Treating empty as "allows nothing" here would make every delegate call + // fail for the common case of a policy that doesn't bother naming tools. + if len(parent.AllowedTools) > 0 && !subset(e.Toolsets, parent.AllowedTools) { + return fmt.Errorf("worker tools expand parent authority") + } + if !classSubset(e.Consequences, parent.ConsequenceClasses) { + return fmt.Errorf("worker consequences expand parent authority") + } + return nil +} +func PrepareRunDirectory(home, runID string, e ExecutionEnvelope) (string, error) { + dir := filepath.Join(home, "runs", runID) + if err := os.MkdirAll(filepath.Join(dir, "scratch"), 0o700); err != nil { + return "", err + } + if err := os.MkdirAll(filepath.Join(dir, "evidence"), 0o700); err != nil { + return "", err + } + b, err := json.MarshalIndent(e, "", " ") + if err != nil { + return "", err + } + if err := atomicfile.WriteFile(filepath.Join(dir, "envelope.json"), b, 0o600); err != nil { + return "", err + } + return dir, nil +} diff --git a/internal/agent/autonomy/delegation_test.go b/internal/agent/autonomy/delegation_test.go new file mode 100644 index 0000000..4619937 --- /dev/null +++ b/internal/agent/autonomy/delegation_test.go @@ -0,0 +1,37 @@ +package autonomy + +import ( + "os" + "path/filepath" + "testing" + + "github.com/memcode-ai/memcode/internal/jobs" +) + +func TestDynamicDelegationNarrowingAndRunDirectory(t *testing.T) { + parent := DelegationPolicy{AllowedTools: []string{"files", "shell"}, ConsequenceClasses: []ConsequenceClass{Observe, LocalMutation}, MaxDelegationDepth: 2} + e := ExecutionEnvelope{Task: "Arbitrary objective-specific investigation", ExpectedOutput: "evidence", CompletionCondition: "evidence recorded", Toolsets: []string{"files"}, Consequences: []ConsequenceClass{Observe}, Budgets: jobs.ExecutionBudgets{MaxSeconds: 60}, DelegationDepth: 1} + if err := ValidateDelegation(parent, e); err != nil { + t.Fatal(err) + } + home := t.TempDir() + dir, err := PrepareRunDirectory(home, "run-1", e) + if err != nil { + t.Fatal(err) + } + for _, p := range []string{"envelope.json", "scratch", "evidence"} { + if _, err := os.Stat(filepath.Join(dir, p)); err != nil { + t.Fatal(err) + } + } + e.Toolsets = []string{"browser"} + if err := ValidateDelegation(parent, e); err == nil { + t.Fatal("expanded worker authority accepted") + } +} +func TestDelegationHasNoRoleRequirement(t *testing.T) { + p := DelegationPolicy{AllowedTools: []string{"files"}, ConsequenceClasses: []ConsequenceClass{Observe}, MaxDelegationDepth: 1} + if err := ValidateDelegation(p, ExecutionEnvelope{Task: "Any dynamically described work", CompletionCondition: "done", Toolsets: []string{"files"}, Consequences: []ConsequenceClass{Observe}}); err != nil { + t.Fatal(err) + } +} diff --git a/internal/agent/autonomy/executive.go b/internal/agent/autonomy/executive.go new file mode 100644 index 0000000..e85845f --- /dev/null +++ b/internal/agent/autonomy/executive.go @@ -0,0 +1,64 @@ +package autonomy + +import ( + "fmt" + "sort" + "time" +) + +type ExecutiveState struct { + Objective Objective + Subgoals []Subgoal + PendingInteractions int + RecentActions []Action + LastEvaluation *EffectivenessEvaluation +} +type ExecutiveDecision struct { + Kind, SubgoalID, Reason string + NextWake *time.Time +} +type EffectivenessEvaluation struct { + Progress float64 + Success bool + Elapsed time.Duration + Cost float64 + RepeatedSteps, Errors, UserCorrections int + EnvironmentalInstability bool + CapabilityGap, Recommendation string +} + +func SelectNextAction(state ExecutiveState, now time.Time) ExecutiveDecision { + if state.Objective.Status == "paused" || state.Objective.Status == "stopped" { + return ExecutiveDecision{Kind: "stop", Reason: "objective is not active"} + } + if state.PendingInteractions > 0 { + return ExecutiveDecision{Kind: "ask", Reason: "human interaction is pending"} + } + eligible := append([]Subgoal(nil), state.Subgoals...) + sort.SliceStable(eligible, func(i, j int) bool { return eligible[i].Priority > eligible[j].Priority }) + for _, g := range eligible { + if g.Status == "pending" || g.Status == "active" { + return ExecutiveDecision{Kind: "execute", SubgoalID: g.ID, Reason: "highest-priority eligible subgoal"} + } + } + next := now.Add(time.Hour) + return ExecutiveDecision{Kind: "defer", Reason: "no eligible subgoal", NextWake: &next} +} +func EvaluateEffectiveness(e EffectivenessEvaluation) ExecutiveDecision { + if e.Success && e.Progress >= 1 { + return ExecutiveDecision{Kind: "complete", Reason: "success criteria satisfied"} + } + if e.Errors >= 3 || e.EnvironmentalInstability { + return ExecutiveDecision{Kind: "change_strategy", Reason: "repeated failure or unstable environment"} + } + if e.RepeatedSteps >= 2 || e.CapabilityGap != "" { + return ExecutiveDecision{Kind: "generate_artifact", Reason: "observed friction or capability gap"} + } + return ExecutiveDecision{Kind: "continue", Reason: "current strategy remains effective"} +} +func ValidateExecutiveBudget(maxSeconds, maxTools, maxDelegation int) error { + if maxSeconds <= 0 || maxTools <= 0 || maxDelegation < 0 { + return fmt.Errorf("executive wakes require positive time/tool budgets and non-negative delegation depth") + } + return nil +} diff --git a/internal/agent/autonomy/executive_test.go b/internal/agent/autonomy/executive_test.go new file mode 100644 index 0000000..a1cdc21 --- /dev/null +++ b/internal/agent/autonomy/executive_test.go @@ -0,0 +1,56 @@ +package autonomy + +import ( + "testing" + "time" +) + +func TestExecutiveSelectionEvaluationAndScheduling(t *testing.T) { + now := time.Date(2026, time.August, 30, 12, 0, 0, 0, time.UTC) + state := ExecutiveState{Objective: Objective{Status: "active"}, Subgoals: []Subgoal{{ID: "low", Status: "pending", Priority: 1}, {ID: "high", Status: "pending", Priority: 5}}} + d := SelectNextAction(state, now) + if d.Kind != "execute" || d.SubgoalID != "high" { + t.Fatalf("decision=%+v", d) + } + state.PendingInteractions = 1 + if d = SelectNextAction(state, now); d.Kind != "ask" { + t.Fatalf("decision=%+v", d) + } + state.PendingInteractions = 0 + state.Subgoals = nil + if d = SelectNextAction(state, now); d.Kind != "defer" || d.NextWake == nil { + t.Fatalf("decision=%+v", d) + } + if d = EvaluateEffectiveness(EffectivenessEvaluation{RepeatedSteps: 3}); d.Kind != "generate_artifact" { + t.Fatalf("decision=%+v", d) + } + if d = EvaluateEffectiveness(EffectivenessEvaluation{Errors: 3}); d.Kind != "change_strategy" { + t.Fatalf("decision=%+v", d) + } + if d = EvaluateEffectiveness(EffectivenessEvaluation{Success: true, Progress: 1}); d.Kind != "complete" { + t.Fatalf("decision=%+v", d) + } +} +func TestSelfEvolutionChoicesFollowObservedFriction(t *testing.T) { + if got := ChooseEvolution(EffectivenessEvaluation{RepeatedSteps: 2}, false); got != EvolutionGenerate { + t.Fatalf("choice=%s", got) + } + if got := ChooseEvolution(EffectivenessEvaluation{CapabilityGap: "missing transform"}, true); got != EvolutionImprove { + t.Fatalf("choice=%s", got) + } + if got := ChooseEvolution(EffectivenessEvaluation{Errors: 3}, false); got != EvolutionChangeStrategy { + t.Fatalf("choice=%s", got) + } + if got := ChooseEvolution(EffectivenessEvaluation{UserCorrections: 2}, false); got != EvolutionEscalate { + t.Fatalf("choice=%s", got) + } +} + +func TestExecutiveBudgetBounded(t *testing.T) { + if err := ValidateExecutiveBudget(60, 10, 2); err != nil { + t.Fatal(err) + } + if err := ValidateExecutiveBudget(0, 10, 2); err == nil { + t.Fatal("unbounded wake accepted") + } +} diff --git a/internal/agent/autonomy/generated.go b/internal/agent/autonomy/generated.go new file mode 100644 index 0000000..19a37e2 --- /dev/null +++ b/internal/agent/autonomy/generated.go @@ -0,0 +1,83 @@ +package autonomy + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" +) + +type GeneratedIndex struct { + Path, Hash, Purpose, SourceObjectiveID, SourceRunID, ParentRevision string + BuildCommand, RunCommand, TestCommand []string + Evaluations []EffectivenessEvaluation + ActiveRevision string +} + +func InitializeGeneratedWorkspace(home string) (string, error) { + root := filepath.Join(home, "workspace", "generated") + if err := os.MkdirAll(root, 0o700); err != nil { + return "", err + } + if _, err := os.Stat(filepath.Join(root, ".git")); os.IsNotExist(err) { + cmd := exec.Command("git", "init", "--quiet") + cmd.Dir = root + if out, err := cmd.CombinedOutput(); err != nil { + return "", fmt.Errorf("initialize generated workspace: %v: %s", err, out) + } + } + return root, nil +} +func CommitGenerated(root, message string) error { + for _, args := range [][]string{{"add", "--all"}, {"-c", "user.name=Memcode Personal", "-c", "user.email=personal@localhost", "commit", "--quiet", "--allow-empty", "-m", message}} { + cmd := exec.Command("git", args...) + cmd.Dir = root + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("git %v: %v: %s", args, err, out) + } + } + return nil +} +func RollbackGenerated(root, revision string) error { + cmd := exec.Command("git", "reset", "--hard", revision) + cmd.Dir = root + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("rollback generated workspace: %v: %s", err, out) + } + return nil +} + +type EvolutionChoice string + +const ( + EvolutionContinue EvolutionChoice = "continue" + EvolutionChangeStrategy EvolutionChoice = "change_strategy" + EvolutionReuse EvolutionChoice = "reuse_artifact" + EvolutionGenerate EvolutionChoice = "generate_artifact" + EvolutionImprove EvolutionChoice = "improve_artifact" + EvolutionRetire EvolutionChoice = "retire_artifact" + EvolutionEscalate EvolutionChoice = "request_information_or_authority" + EvolutionAbandon EvolutionChoice = "abandon" +) + +func ChooseEvolution(e EffectivenessEvaluation, hasCompatibleArtifact bool) EvolutionChoice { + if e.Success && e.Progress >= 1 { + return EvolutionContinue + } + if e.UserCorrections >= 2 { + return EvolutionEscalate + } + if e.Errors >= 3 { + return EvolutionChangeStrategy + } + if e.RepeatedSteps >= 2 || e.CapabilityGap != "" { + if hasCompatibleArtifact { + return EvolutionImprove + } + return EvolutionGenerate + } + if e.Cost > 0 && hasCompatibleArtifact { + return EvolutionReuse + } + return EvolutionContinue +} diff --git a/internal/agent/autonomy/interactions.go b/internal/agent/autonomy/interactions.go new file mode 100644 index 0000000..8d57679 --- /dev/null +++ b/internal/agent/autonomy/interactions.go @@ -0,0 +1,111 @@ +package autonomy + +import ( + "context" + "database/sql" + "fmt" + "time" +) + +// Interaction is a durable human-in-the-loop request created by a suspending +// tool (ask_user). It lives in the agent's personal.db and is answered via +// `personal answer`. Resume is exact: the saved tool_use_id gets the answer. +type Interaction struct { + ID, AgentID, ObjectiveID, RunID string + Kind, Question, Context string + Answer *string + Status string // pending | answered | cancelled + ToolUseID string + CreatedAt time.Time + AnsweredAt *time.Time +} + +func (s *Store) InsertInteraction(ctx context.Context, in Interaction) error { + if in.CreatedAt.IsZero() { + in.CreatedAt = time.Now().UTC() + } + if in.Status == "" { + in.Status = "pending" + } + _, err := s.db.ExecContext(ctx, `INSERT INTO interactions(id,agent_id,objective_id,run_id,kind,question,context,status,tool_use_id,created_at) VALUES(?,?,?,?,?,?,?,?,?,?)`, + in.ID, in.AgentID, in.ObjectiveID, in.RunID, in.Kind, in.Question, in.Context, in.Status, in.ToolUseID, stamp(in.CreatedAt)) + return err +} + +func scanInteraction(row interface{ Scan(...any) error }) (Interaction, error) { + var in Interaction + var answer, answered sql.NullString + var created string + err := row.Scan(&in.ID, &in.AgentID, &in.ObjectiveID, &in.RunID, &in.Kind, &in.Question, &in.Context, &answer, &in.Status, &in.ToolUseID, &created, &answered) + if err != nil { + return in, err + } + in.CreatedAt, _ = time.Parse(time.RFC3339Nano, created) + if answer.Valid { + in.Answer = &answer.String + } + if answered.Valid { + t, _ := time.Parse(time.RFC3339Nano, answered.String) + in.AnsweredAt = &t + } + return in, nil +} + +const interactionCols = `id,agent_id,objective_id,run_id,kind,question,context,answer,status,tool_use_id,created_at,answered_at` + +func (s *Store) PendingInteractions(ctx context.Context, agentID string) ([]Interaction, error) { + rows, err := s.db.QueryContext(ctx, `SELECT `+interactionCols+` FROM interactions WHERE agent_id=? AND status='pending' ORDER BY created_at`, agentID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Interaction + for rows.Next() { + in, err := scanInteraction(rows) + if err != nil { + return nil, err + } + out = append(out, in) + } + return out, rows.Err() +} + +func (s *Store) GetInteraction(ctx context.Context, id string) (Interaction, bool, error) { + in, err := scanInteraction(s.db.QueryRowContext(ctx, `SELECT `+interactionCols+` FROM interactions WHERE id=?`, id)) + if err == sql.ErrNoRows { + return Interaction{}, false, nil + } + if err != nil { + return Interaction{}, false, err + } + return in, true, nil +} + +// ResolveInteraction atomically marks a pending interaction answered. Returns an +// error if it was already resolved (prevents double-resume of a suspended run). +func (s *Store) ResolveInteraction(ctx context.Context, id, answer string) error { + res, err := s.db.ExecContext(ctx, `UPDATE interactions SET status='answered',answer=?,answered_at=? WHERE id=? AND status='pending'`, answer, stamp(time.Now().UTC()), id) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n != 1 { + return fmt.Errorf("interaction %q is not pending (already answered or cancelled)", id) + } + return nil +} + +func (s *Store) CancelInteraction(ctx context.Context, id string) error { + _, err := s.db.ExecContext(ctx, `UPDATE interactions SET status='cancelled' WHERE id=? AND status='pending'`, id) + return err +} + +// Package-level wrappers used by cmd (store passed explicitly). +func PendingInteractions(s *Store, agentID string) ([]Interaction, error) { + return s.PendingInteractions(context.Background(), agentID) +} +func GetInteraction(s *Store, id string) (Interaction, bool, error) { + return s.GetInteraction(context.Background(), id) +} +func ResolveInteraction(s *Store, id, answer string) error { + return s.ResolveInteraction(context.Background(), id, answer) +} diff --git a/internal/agent/autonomy/memory.go b/internal/agent/autonomy/memory.go new file mode 100644 index 0000000..8e9d2e8 --- /dev/null +++ b/internal/agent/autonomy/memory.go @@ -0,0 +1,69 @@ +package autonomy + +import ( + "os" + "path/filepath" + "strings" +) + +// memoryFile is the agent's durable semantic memory — the same memory.md every +// memcode agent already has in its home, injected into ordinary conversations +// by the runtime. An unattended agent writes to it with the `remember` tool and +// reads it back on every wake, so what it learns once ("Tim is a US citizen and +// needs no sponsorship") is known forever and never asked again. +// +// This replaces a structured `facts` table that carried key/value/source/ +// confirmed/sensitivity. That table's provenance was never actually enforced — +// nothing read Confirmed to gate anything — and it meant an agent had two +// unrelated places to put what it knew, only one of which a human could read. +// +// The tradeoff is deliberate and worth naming: prose cannot distinguish "you +// told me this" from "I inferred it from your resume" from "a website said so", +// nor mark a claim as safe to assert on the user's behalf, nor mark it stale. +// That distinction becomes load-bearing the moment an agent fills in a form or +// sends a message stating something about the user. When that lands, structured +// provenance should come back as its own thing in the store (machine-checkable, +// alongside policies and the action journal) — not by reviving this table. +const memoryFile = "memory.md" + +func memoryPath(home string) string { return filepath.Join(home, memoryFile) } + +// ReadMemory returns the agent's memory, or "" when it has none yet. +func ReadMemory(home string) string { + b, err := os.ReadFile(memoryPath(home)) + if err != nil { + return "" + } + return strings.TrimSpace(string(b)) +} + +// AppendMemory adds one durable note. Append-only and deduplicated: a wake that +// re-learns something it already recorded must not grow the file without bound, +// since every line is replayed into the model on every future wake. +func AppendMemory(home, note string) error { + note = strings.TrimSpace(strings.ReplaceAll(note, "\n", " ")) + if note == "" { + return nil + } + existing := ReadMemory(home) + for _, line := range strings.Split(existing, "\n") { + if strings.EqualFold(strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "- ")), note) { + return nil // already known + } + } + if err := os.MkdirAll(home, 0o700); err != nil { + return err + } + f, err := os.OpenFile(memoryPath(home), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) + if err != nil { + return err + } + defer f.Close() + var b strings.Builder + if existing == "" { + b.WriteString("# Memory\n\n") + } + b.WriteString("- " + note + "\n") + _, err = f.WriteString(b.String()) + return err +} diff --git a/internal/agent/autonomy/memory_test.go b/internal/agent/autonomy/memory_test.go new file mode 100644 index 0000000..f3bf644 --- /dev/null +++ b/internal/agent/autonomy/memory_test.go @@ -0,0 +1,50 @@ +package autonomy + +import ( + "strings" + "testing" +) + +func TestMemoryAppendReadAndDedup(t *testing.T) { + home := t.TempDir() + if got := ReadMemory(home); got != "" { + t.Fatalf("fresh agent should have no memory, got %q", got) + } + if err := AppendMemory(home, "Tim is a US citizen and needs no visa sponsorship (he said so directly)."); err != nil { + t.Fatal(err) + } + if err := AppendMemory(home, "Prefers backend roles at Series B-D startups."); err != nil { + t.Fatal(err) + } + mem := ReadMemory(home) + if !strings.Contains(mem, "US citizen") || !strings.Contains(mem, "Series B-D") { + t.Fatalf("memory missing entries: %q", mem) + } + + // Re-learning the same thing must not grow the file: every line is replayed + // into the model on every future wake, so duplicates cost context forever. + if err := AppendMemory(home, "prefers backend roles at series b-d startups."); err != nil { + t.Fatal(err) + } + if n := strings.Count(ReadMemory(home), "Series B-D"); n != 1 { + t.Fatalf("duplicate note recorded %d times", n) + } + + // A multi-line note collapses to one line — the file is a list, and a + // stray newline would fake a second entry. + if err := AppendMemory(home, "line one\nline two"); err != nil { + t.Fatal(err) + } + for _, line := range strings.Split(ReadMemory(home), "\n") { + if strings.TrimSpace(line) == "line two" { + t.Fatal("multi-line note split into separate entries") + } + } + + if err := AppendMemory(home, " "); err != nil { + t.Fatal(err) + } + if strings.Contains(ReadMemory(home), "- \n") { + t.Fatal("blank note recorded") + } +} diff --git a/internal/agent/autonomy/migrations/002_interactions.sql b/internal/agent/autonomy/migrations/002_interactions.sql new file mode 100644 index 0000000..0838ab7 --- /dev/null +++ b/internal/agent/autonomy/migrations/002_interactions.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS interactions ( + id TEXT PRIMARY KEY, agent_id TEXT NOT NULL, objective_id TEXT NOT NULL, run_id TEXT NOT NULL, + kind TEXT NOT NULL, question TEXT NOT NULL, context TEXT NOT NULL DEFAULT '', answer TEXT, + status TEXT NOT NULL DEFAULT 'pending', tool_use_id TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, answered_at TEXT +); +CREATE INDEX IF NOT EXISTS idx_interactions_agent ON interactions(agent_id, status); \ No newline at end of file diff --git a/internal/agent/autonomy/migrations/003_action_job_id.sql b/internal/agent/autonomy/migrations/003_action_job_id.sql new file mode 100644 index 0000000..998ae34 --- /dev/null +++ b/internal/agent/autonomy/migrations/003_action_job_id.sql @@ -0,0 +1,8 @@ +-- A delegated action spawns a detached job, and check_delegate has to find its +-- way back from the job id to the action it must close out. That mapping used +-- to be written into the facts table ("delegation."), abusing a +-- semantic-memory store as a keyed index because it was the only durable place +-- delegate could write without a migration. This is that migration: the link +-- belongs on the action itself. +ALTER TABLE actions ADD COLUMN job_id TEXT; +CREATE INDEX IF NOT EXISTS actions_job ON actions(job_id) WHERE job_id IS NOT NULL; diff --git a/internal/agent/autonomy/mirror.go b/internal/agent/autonomy/mirror.go new file mode 100644 index 0000000..6116673 --- /dev/null +++ b/internal/agent/autonomy/mirror.go @@ -0,0 +1,86 @@ +package autonomy + +import ( + "context" + "encoding/json" + "path/filepath" + + yaml "go.yaml.in/yaml/v4" + + "github.com/memcode-ai/memcode/internal/atomicfile" +) + +// WriteConfigMirror regenerates config.yaml — ONE readable file in the agent's +// home holding the authority state that lives in the database: its policies +// (draft and approved) and its resource grants. This is what makes +// `ls ~/.memcode/agents//` show something a person can read, diff, and +// grep instead of only an opaque SQLite file, matching every other piece of +// memcode config (gateway.yaml, .mcp.json, MEMCODE.md, skills). +// +// The agent's objective, autonomy, browser mode, and pause state are NOT here: +// they are ordinary configuration in gateway.yaml, which is already a readable +// file. Mirroring them too would mean two places to look and two chances to +// disagree. +// +// This file is a MIRROR, not the source of truth — the DB stays authoritative +// for two reasons that are correctness, not habit: +// - Policy approval is a deliberate hash-gated ceremony (see +// ApprovePolicy): a autonomous agent runs unsupervised, so "the document a +// human actually reviewed" must be pinned by hash, not re-derived from +// whatever the file happens to say at wake time. Editing config.yaml's +// policy section and having it silently take effect would defeat that. +// - The action/trigger/interaction journal needs atomic claim/complete +// semantics under concurrent access (the gateway wake loop and an admin +// session can both touch the same agent) — a SQL transaction gives that +// almost for free; a flat file would need to reinvent it (see the +// atomicfile-write fix elsewhere in this package for how easily a plain +// file write loses that property). So the run journal stays out of this +// file entirely — read it with gw_journal. +// +// Called after every mutation to policies/resources (gw_policy, gw_grant), +// best-effort: a mirror failure never blocks the underlying write, which has +// already succeeded. +func WriteConfigMirror(ctx context.Context, home string, s *Store) error { + type policyView struct { + Hash string `yaml:"hash"` + Status string `yaml:"status"` + Version int `yaml:"version"` + Approved bool `yaml:"approved"` + Document map[string]any `yaml:"document"` + } + type resourceView struct { + ID string `yaml:"id"` + Type string `yaml:"type"` + Locator string `yaml:"locator"` + AccessMode string `yaml:"access_mode"` + Status string `yaml:"status"` + } + cfg := struct { + Policies []policyView `yaml:"policies,omitempty"` + Resources []resourceView `yaml:"resources,omitempty"` + }{} + + policies, err := s.ListPolicies(ctx, "primary") + if err != nil { + return err + } + for _, p := range policies { + var doc map[string]any + _ = json.Unmarshal(p.Document, &doc) + cfg.Policies = append(cfg.Policies, policyView{Hash: p.Hash, Status: p.Status, Version: p.Version, Approved: p.Status == "approved", Document: doc}) + } + + res, err := s.ListResources(ctx, "primary") + if err != nil { + return err + } + for _, r := range res { + cfg.Resources = append(cfg.Resources, resourceView{ID: r.ID, Type: r.Type, Locator: r.Locator, AccessMode: r.AccessMode, Status: r.Status}) + } + + b, err := yaml.Marshal(cfg) + if err != nil { + return err + } + return atomicfile.WriteFile(filepath.Join(home, "config.yaml"), b, 0o600) +} diff --git a/internal/agent/autonomy/model.go b/internal/agent/autonomy/model.go new file mode 100644 index 0000000..7cbec74 --- /dev/null +++ b/internal/agent/autonomy/model.go @@ -0,0 +1,72 @@ +// Package personal implements the domain-general durable state and runtime +// primitives for autonomous agents. +package autonomy + +import ( + "encoding/json" + "time" +) + +type Objective struct { + ID, Description, SuccessCriteria, Status string + Priority int + CreatedAt, UpdatedAt time.Time + ReviewAt *time.Time +} + +type Subgoal struct { + ID, ObjectiveID, ParentID, Description, Status, Rationale string + Priority int + Dependencies json.RawMessage + CreatedAt, UpdatedAt time.Time +} + +type Run struct { + ID, ObjectiveID, SubgoalID, ParentRunID, SessionID string + Envelope, Outcome, Evidence json.RawMessage + Status string + CreatedAt, UpdatedAt time.Time +} + +type Trigger struct { + ID, ObjectiveID, Kind, Spec, MissedRunPolicy, Status string + NextDueAt, LastFiredAt *time.Time + CreatedAt, UpdatedAt time.Time +} + +type Policy struct { + ID, ObjectiveID, Hash, Status string + Version int + Document json.RawMessage + ApprovedAt *time.Time + CreatedAt time.Time +} + +type Resource struct { + ID, ObjectiveID, Type, Locator, AccessMode, AuthorizationSource, PolicyHash, Status string + Constraints json.RawMessage + ExpiresAt *time.Time + CreatedAt, UpdatedAt time.Time +} + +type Action struct { + ID, ObjectiveID, SubgoalID, RunID, Kind, Target, ConsequenceClass, PolicyHash, Status, IdempotencyKey string + // JobID links a delegate action to the detached job it spawned, so a later + // wake can find the action to close out (see Store.ActionForJob). + JobID string + Request, Result, Evidence json.RawMessage + CreatedAt, UpdatedAt time.Time +} + +type GeneratedItem struct { + ID, ObjectiveID, Path, Hash, Purpose, SourceRunID, ParentRevision, ActiveRevision string + Invocation, Evaluations json.RawMessage + LastUsedAt *time.Time + CreatedAt, UpdatedAt time.Time +} + +type Notification struct { + ID, ObjectiveID, Kind, Status string + Payload json.RawMessage + CreatedAt, UpdatedAt time.Time +} diff --git a/internal/agent/autonomy/pacing.go b/internal/agent/autonomy/pacing.go new file mode 100644 index 0000000..99db0d1 --- /dev/null +++ b/internal/agent/autonomy/pacing.go @@ -0,0 +1,68 @@ +package autonomy + +import ( + "math/rand" + "time" +) + +type PacePolicy struct { + BurstCap, PeriodLimit, Concurrency int + MinimumCooldown, BaseBackoff, MaxBackoff time.Duration + QuietStart, QuietEnd int +} +type PaceState struct { + PeriodStarted time.Time + Actions, ConsecutiveFailures int + CooldownUntil time.Time + Suspended bool + Warning string +} + +func (s PaceState) Allow(now time.Time, p PacePolicy) bool { + if s.Suspended || now.Before(s.CooldownUntil) { + return false + } + hour := now.Hour() + if p.QuietStart != p.QuietEnd { + if p.QuietStart < p.QuietEnd && hour >= p.QuietStart && hour < p.QuietEnd { + return false + } + if p.QuietStart > p.QuietEnd && (hour >= p.QuietStart || hour < p.QuietEnd) { + return false + } + } + if p.BurstCap > 0 && s.Actions >= p.BurstCap { + return false + } + return true +} +func (s PaceState) AfterFailure(now time.Time, p PacePolicy, warning bool) PaceState { + s.ConsecutiveFailures++ + backoff := p.BaseBackoff + if backoff <= 0 { + backoff = time.Second + } + for i := 1; i < s.ConsecutiveFailures; i++ { + backoff *= 2 + if p.MaxBackoff > 0 && backoff >= p.MaxBackoff { + backoff = p.MaxBackoff + break + } + } + if backoff < p.MinimumCooldown { + backoff = p.MinimumCooldown + } + jitter := time.Duration(rand.Int63n(int64(backoff/10 + 1))) + s.CooldownUntil = now.Add(backoff + jitter) + if warning { + s.Suspended = true + s.Warning = "environment warning or challenge" + } + return s +} +func (s PaceState) AfterSuccess(now time.Time, p PacePolicy) PaceState { + s.Actions++ + s.ConsecutiveFailures = 0 + s.CooldownUntil = now.Add(p.MinimumCooldown) + return s +} diff --git a/internal/agent/autonomy/pacing_test.go b/internal/agent/autonomy/pacing_test.go new file mode 100644 index 0000000..39b6df4 --- /dev/null +++ b/internal/agent/autonomy/pacing_test.go @@ -0,0 +1,37 @@ +package autonomy + +import ( + "testing" + "time" +) + +func TestPacingBurstCooldownBackoffAndWarningSuspension(t *testing.T) { + now := time.Date(2026, time.August, 30, 12, 0, 0, 0, time.UTC) + p := PacePolicy{BurstCap: 2, MinimumCooldown: time.Minute, BaseBackoff: time.Second, MaxBackoff: time.Hour, QuietStart: 22, QuietEnd: 6} + s := PaceState{} + if !s.Allow(now, p) { + t.Fatal("initial action denied") + } + s = s.AfterSuccess(now, p) + if s.Allow(now.Add(30*time.Second), p) { + t.Fatal("cooldown ignored") + } + s.CooldownUntil = now + s.Actions = 2 + if s.Allow(now, p) { + t.Fatal("burst cap ignored") + } + s = PaceState{}.AfterFailure(now, p, false) + first := s.CooldownUntil + if !first.After(now) { + t.Fatal("backoff missing") + } + s = s.AfterFailure(now, p, true) + if !s.Suspended || s.Warning == "" { + t.Fatal("warning did not suspend") + } + quiet := time.Date(2026, time.August, 30, 23, 0, 0, 0, time.UTC) + if (PaceState{}).Allow(quiet, p) { + t.Fatal("quiet hours ignored") + } +} diff --git a/internal/agent/autonomy/policy.go b/internal/agent/autonomy/policy.go new file mode 100644 index 0000000..882c7c6 --- /dev/null +++ b/internal/agent/autonomy/policy.go @@ -0,0 +1,136 @@ +package autonomy + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "sort" + "time" +) + +type ConsequenceClass string + +const ( + Observe ConsequenceClass = "observe" + LocalMutation ConsequenceClass = "local_mutation" + ExternalEffect ConsequenceClass = "external_effect" + ExternalRepresentation ConsequenceClass = "external_representation" + Financial ConsequenceClass = "financial" + LegalAttestation ConsequenceClass = "legal_attestation" + Destructive ConsequenceClass = "destructive" +) + +type DelegationPolicy struct { + ObjectiveScope string `json:"objective_scope"` + AllowedTools []string `json:"allowed_tools,omitempty"` + FilesystemRoots map[string]string `json:"filesystem_roots,omitempty"` + BrowserOrigins []string `json:"browser_origins,omitempty"` + MCPTools []string `json:"mcp_tools,omitempty"` + ConsequenceClasses []ConsequenceClass `json:"consequence_classes,omitempty"` + MaxActionsPerPeriod int `json:"max_actions_per_period,omitempty"` + MaxConcurrency int `json:"max_concurrency,omitempty"` + MaxDelegationDepth int `json:"max_delegation_depth,omitempty"` + MaxTokens int `json:"max_tokens,omitempty"` + MaxSeconds int `json:"max_seconds,omitempty"` + GeneratedCode bool `json:"generated_code,omitempty"` + QuietHours string `json:"quiet_hours,omitempty"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + Revoked bool `json:"revoked,omitempty"` +} + +func CanonicalPolicy(p DelegationPolicy) ([]byte, string, error) { + sort.Strings(p.AllowedTools) + sort.Strings(p.BrowserOrigins) + sort.Strings(p.MCPTools) + sort.Slice(p.ConsequenceClasses, func(i, j int) bool { return p.ConsequenceClasses[i] < p.ConsequenceClasses[j] }) + b, err := json.Marshal(p) + if err != nil { + return nil, "", err + } + var compact bytes.Buffer + if err := json.Compact(&compact, b); err != nil { + return nil, "", err + } + sum := sha256.Sum256(compact.Bytes()) + return compact.Bytes(), hex.EncodeToString(sum[:]), nil +} + +func (p DelegationPolicy) AllowsConsequence(c ConsequenceClass, now time.Time) bool { + if p.Revoked || (p.ExpiresAt != nil && !now.Before(*p.ExpiresAt)) { + return false + } + for _, allowed := range p.ConsequenceClasses { + if allowed == c { + return true + } + } + return false +} + +func IsRestriction(parent, next DelegationPolicy) bool { + return subset(next.AllowedTools, parent.AllowedTools) && + classSubset(next.ConsequenceClasses, parent.ConsequenceClasses) && + subset(next.BrowserOrigins, parent.BrowserOrigins) && + subset(next.MCPTools, parent.MCPTools) && + filesystemRootsSubset(next.FilesystemRoots, parent.FilesystemRoots) && + next.MaxConcurrency <= parent.MaxConcurrency && + next.MaxDelegationDepth <= parent.MaxDelegationDepth && + boundedBy(next.MaxActionsPerPeriod, parent.MaxActionsPerPeriod) && + boundedBy(next.MaxTokens, parent.MaxTokens) && + boundedBy(next.MaxSeconds, parent.MaxSeconds) && + (!next.GeneratedCode || parent.GeneratedCode) && + (parent.QuietHours == "" || next.QuietHours == parent.QuietHours) +} + +// boundedBy compares budget fields where 0 means "unset — defer to the +// runtime's own default" rather than literally zero (see nonzero() in +// runner_exec.go). A child leaving one unset is never a widening of +// authority; a child that sets an explicit value must not exceed a parent +// value that is itself explicit. +func boundedBy(next, parent int) bool { + return next == 0 || parent == 0 || next <= parent +} +func NarrowPolicy(parent, child DelegationPolicy) (DelegationPolicy, error) { + if !IsRestriction(parent, child) { + return DelegationPolicy{}, fmt.Errorf("delegated policy expands parent authority") + } + return child, nil +} +func subset(a, b []string) bool { + set := map[string]bool{} + for _, v := range b { + set[v] = true + } + for _, v := range a { + if !set[v] { + return false + } + } + return true +} + +// filesystemRootsSubset reports whether every root the child grants is also +// granted by the parent, under the same access mode — the child cannot claim +// a path outside the parent's roots, nor upgrade access on one it shares. +func filesystemRootsSubset(child, parent map[string]string) bool { + for path, mode := range child { + if parent[path] != mode { + return false + } + } + return true +} +func classSubset(a, b []ConsequenceClass) bool { + set := map[ConsequenceClass]bool{} + for _, v := range b { + set[v] = true + } + for _, v := range a { + if !set[v] { + return false + } + } + return true +} diff --git a/internal/agent/autonomy/policy_test.go b/internal/agent/autonomy/policy_test.go new file mode 100644 index 0000000..2f6aa6b --- /dev/null +++ b/internal/agent/autonomy/policy_test.go @@ -0,0 +1,55 @@ +package autonomy + +import ( + "testing" + "time" +) + +func TestPolicyHashIsCanonical(t *testing.T) { + a := DelegationPolicy{ObjectiveScope: "objective", AllowedTools: []string{"shell", "files"}, ConsequenceClasses: []ConsequenceClass{ExternalEffect, Observe}} + b := DelegationPolicy{ObjectiveScope: "objective", AllowedTools: []string{"files", "shell"}, ConsequenceClasses: []ConsequenceClass{Observe, ExternalEffect}} + _, ha, err := CanonicalPolicy(a) + if err != nil { + t.Fatal(err) + } + _, hb, err := CanonicalPolicy(b) + if err != nil { + t.Fatal(err) + } + if ha != hb { + t.Fatalf("hashes differ: %s %s", ha, hb) + } +} + +func TestPolicyRestrictionDelegationAndRevocation(t *testing.T) { + parent := DelegationPolicy{AllowedTools: []string{"files", "shell"}, ConsequenceClasses: []ConsequenceClass{Observe, LocalMutation}, MaxConcurrency: 2, MaxDelegationDepth: 2, GeneratedCode: true} + child := DelegationPolicy{AllowedTools: []string{"files"}, ConsequenceClasses: []ConsequenceClass{Observe}, MaxConcurrency: 1, MaxDelegationDepth: 1} + if !IsRestriction(parent, child) { + t.Fatal("valid restriction rejected") + } + if _, err := NarrowPolicy(parent, child); err != nil { + t.Fatal(err) + } + expanded := child + expanded.ConsequenceClasses = []ConsequenceClass{ExternalEffect} + if _, err := NarrowPolicy(parent, expanded); err == nil { + t.Fatal("authority expansion accepted") + } + now := time.Date(2026, time.August, 30, 12, 0, 0, 0, time.UTC) + if !parent.AllowsConsequence(Observe, now) { + t.Fatal("allowed consequence denied") + } + parent.Revoked = true + if parent.AllowsConsequence(Observe, now) { + t.Fatal("revoked policy allowed action") + } +} + +func TestPolicyExpiration(t *testing.T) { + now := time.Date(2026, time.August, 30, 12, 0, 0, 0, time.UTC) + expired := now.Add(-time.Second) + p := DelegationPolicy{ConsequenceClasses: []ConsequenceClass{Observe}, ExpiresAt: &expired} + if p.AllowsConsequence(Observe, now) { + t.Fatal("expired policy allowed action") + } +} diff --git a/internal/agent/autonomy/resources.go b/internal/agent/autonomy/resources.go new file mode 100644 index 0000000..af5af00 --- /dev/null +++ b/internal/agent/autonomy/resources.go @@ -0,0 +1,104 @@ +package autonomy + +import ( + "os" + "path/filepath" + "strings" + "time" +) + +type ResourceType string + +const ( + ResourceFilesystem ResourceType = "filesystem" + ResourceBrowser ResourceType = "browser" + ResourceMCP ResourceType = "mcp" + ResourceCommand ResourceType = "command" + ResourceRepository ResourceType = "repository" + ResourceCloud ResourceType = "cloud" + ResourceDocument ResourceType = "document" + ResourceChannel ResourceType = "channel" + ResourceGeneratedProcess ResourceType = "generated_process" +) + +type ResourceGrantModel struct { + ID string + Type ResourceType + Locator, AccessMode string + Constraints map[string]any + AuthorizationSource, PolicyHash, Status string + ExpiresAt *time.Time +} + +func (g ResourceGrantModel) Active(now time.Time) bool { + return g.Status == "active" && (g.ExpiresAt == nil || now.Before(*g.ExpiresAt)) +} +func CanonicalFilesystemGrant(path string) (string, error) { + if strings.HasPrefix(path, "~/") { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + path = filepath.Join(home, strings.TrimPrefix(path, "~/")) + } + abs, err := filepath.Abs(path) + if err != nil { + return "", err + } + resolved, err := filepath.EvalSymlinks(abs) + if err != nil { + return "", err + } + // A grant may be a single file (e.g. a resume) or a directory root — both + // work with PathWithinGrant unchanged (a file grant's only "contained" + // path is itself: rel == "."). Requiring a directory here would force + // granting a whole folder just to share one file, which is both more + // ceremony and a broader grant than the task needs. + if _, err := os.Stat(resolved); err != nil { + return "", err + } + return resolved, nil +} + +// PathWithinGrant reports whether path resolves (symlinks evaluated) to a +// location inside the canonical grant root. The requested path's symlinks are +// resolved before the containment check so a symlink inside a granted dir that +// points outside cannot escape the boundary. +func PathWithinGrant(path, root string) bool { + // Resolve the path fully. For a write to a not-yet-existing file, EvalSymlinks + // fails on the leaf; resolve the deepest existing ancestor and re-join the rest. + resolvedPath := resolveDeep(path) + resolvedRoot := resolveDeep(root) + rel, err := filepath.Rel(resolvedRoot, resolvedPath) + if err != nil { + return false + } + return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))) +} + +// resolveDeep resolves symlinks on the longest existing prefix of path, then +// re-attaches the non-existent tail. This lets us contain a write to a new file +// while still catching a symlinked parent that escapes the grant. +func resolveDeep(path string) string { + abs, err := filepath.Abs(path) + if err != nil { + return path + } + if r, err := filepath.EvalSymlinks(abs); err == nil { + return r + } + // Walk up until an existing ancestor resolves. + dir := abs + var tail []string + for { + parent := filepath.Dir(dir) + if parent == dir { + return abs + } + tail = append([]string{filepath.Base(dir)}, tail...) + dir = parent + if r, err := filepath.EvalSymlinks(dir); err == nil { + return filepath.Join(append([]string{r}, tail...)...) + } + } +} diff --git a/internal/agent/autonomy/resources_test.go b/internal/agent/autonomy/resources_test.go new file mode 100644 index 0000000..64e668e --- /dev/null +++ b/internal/agent/autonomy/resources_test.go @@ -0,0 +1,68 @@ +package autonomy + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestResourceGrantCanonicalBoundaryAndExpiration(t *testing.T) { + root := t.TempDir() + canonical, err := CanonicalFilesystemGrant(root) + if err != nil { + t.Fatal(err) + } + if !PathWithinGrant(filepath.Join(canonical, "child"), canonical) { + t.Fatal("granted child denied") + } + if PathWithinGrant(filepath.Dir(canonical), canonical) { + t.Fatal("path outside grant allowed") + } + now := time.Date(2026, time.August, 30, 12, 0, 0, 0, time.UTC) + future := now.Add(time.Hour) + g := ResourceGrantModel{Status: "active", ExpiresAt: &future} + if !g.Active(now) { + t.Fatal("active grant denied") + } + past := now.Add(-time.Hour) + g.ExpiresAt = &past + if g.Active(now) { + t.Fatal("expired grant allowed") + } +} + +// Regression: a symlink inside a granted dir pointing outside must NOT satisfy +// the grant (Codex P0). PathWithinGrant resolves the requested path's symlinks. +func TestPathWithinGrantRejectsSymlinkEscape(t *testing.T) { + grant := t.TempDir() + outside := t.TempDir() + secret := filepath.Join(outside, "secret.txt") + if err := os.WriteFile(secret, []byte("s3cret"), 0o600); err != nil { + t.Fatal(err) + } + // Symlink inside the grant pointing to the outside file. + link := filepath.Join(grant, "escape.txt") + if err := os.Symlink(secret, link); err != nil { + t.Fatal(err) + } + if PathWithinGrant(link, grant) { + t.Fatal("symlink to outside path was treated as within grant") + } + // A symlinked DIRECTORY inside the grant pointing outside must also fail. + linkDir := filepath.Join(grant, "out") + if err := os.Symlink(outside, linkDir); err != nil { + t.Fatal(err) + } + if PathWithinGrant(filepath.Join(linkDir, "secret.txt"), grant) { + t.Fatal("symlinked dir escape treated as within grant") + } + // A genuine in-grant path still passes. + real := filepath.Join(grant, "real.txt") + if err := os.WriteFile(real, []byte("ok"), 0o600); err != nil { + t.Fatal(err) + } + if !PathWithinGrant(real, grant) { + t.Fatal("in-grant path rejected") + } +} diff --git a/internal/agent/autonomy/runner.go b/internal/agent/autonomy/runner.go new file mode 100644 index 0000000..2cccffe --- /dev/null +++ b/internal/agent/autonomy/runner.go @@ -0,0 +1,106 @@ +package autonomy + +import ( + "bytes" + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "time" +) + +type RunSpec struct { + Executable string + Args []string + Inputs map[string][]byte + AllowedExecutables []string + Timeout time.Duration + MaxOutputBytes int + Environment map[string]string + RequireHardenedSandbox bool +} +type RunResult struct { + Stdout, Stderr string + ExitCode int + ChangedFiles []string +} + +func RunGenerated(ctx context.Context, s RunSpec) (RunResult, error) { + if s.RequireHardenedSandbox && !SandboxAvailable() { + return RunResult{}, fmt.Errorf("enforceable generated-code sandbox is unavailable") + } + if !subset([]string{s.Executable}, s.AllowedExecutables) { + return RunResult{}, fmt.Errorf("executable %q is not allowed", s.Executable) + } + dir, err := os.MkdirTemp("", "memcode-personal-run-") + if err != nil { + return RunResult{}, err + } + defer os.RemoveAll(dir) + for p, b := range s.Inputs { + clean := filepath.Clean(p) + if filepath.IsAbs(clean) || clean == ".." { + return RunResult{}, fmt.Errorf("invalid staged input %q", p) + } + full := filepath.Join(dir, clean) + if err := os.MkdirAll(filepath.Dir(full), 0o700); err != nil { + return RunResult{}, err + } + if err := os.WriteFile(full, b, 0o600); err != nil { + return RunResult{}, err + } + } + timeout := s.Timeout + if timeout <= 0 { + timeout = 30 * time.Second + } + runCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + cmd := exec.CommandContext(runCtx, s.Executable, s.Args...) + cmd.Dir = dir + cmd.Env = []string{"PATH=/usr/bin:/bin", "HOME=" + dir, "TMPDIR=" + dir} + for k, v := range s.Environment { + cmd.Env = append(cmd.Env, k+"="+v) + } + var stdout, stderr bytes.Buffer + cmd.Stdout = &limitedWriter{w: &stdout, n: limit(s.MaxOutputBytes)} + cmd.Stderr = &limitedWriter{w: &stderr, n: limit(s.MaxOutputBytes)} + err = cmd.Run() + result := RunResult{Stdout: stdout.String(), Stderr: stderr.String()} + if err != nil { + if ee, ok := err.(*exec.ExitError); ok { + result.ExitCode = ee.ExitCode() + } else { + return result, err + } + } + return result, nil +} +func SandboxAvailable() bool { return runtime.GOOS == "linux" && commandExists("bwrap") } +func commandExists(name string) bool { _, err := exec.LookPath(name); return err == nil } +func limit(n int) int { + if n <= 0 { + return 1 << 20 + } + return n +} + +type limitedWriter struct { + w *bytes.Buffer + n int +} + +func (l *limitedWriter) Write(p []byte) (int, error) { + orig := len(p) + if l.n <= 0 { + return orig, nil + } + if len(p) > l.n { + p = p[:l.n] + } + _, err := l.w.Write(p) + l.n -= len(p) + return orig, err +} diff --git a/internal/agent/autonomy/runner_exec.go b/internal/agent/autonomy/runner_exec.go new file mode 100644 index 0000000..8833394 --- /dev/null +++ b/internal/agent/autonomy/runner_exec.go @@ -0,0 +1,768 @@ +package autonomy + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/memcode-ai/memcode/internal/agent/continuation" + "github.com/memcode-ai/memcode/internal/browser/broker" + "github.com/memcode-ai/memcode/internal/jobs" + "github.com/memcode-ai/memcode/internal/llm" + "github.com/memcode-ai/memcode/internal/wire" +) + +// Executive is one bounded decision loop for an agent running unattended. Each RunOnce is a +// single bounded wake: read durable state, run one LLM turn with domain-neutral +// tools, journal consequential actions, then complete, schedule the next wake, +// or suspend for human input. It never holds an open loop. +type Executive struct { + Store *Store + Home string + AgentID string + // Objective is the durable outcome this wake advances, read from the + // agent's configuration (gwconfig.Agent.Objective) rather than the store — + // a human edits it in one place and it hot-reloads. An empty Objective + // blocks the run rather than inventing one. + Objective string + Runner *llm.Runner + Now func() time.Time + MaxSteps int + // DelegationDepth is this wake's own depth in a delegation chain — 0 for a + // top-level RunOnce/ResumeSuspended wake. A worker spawned via delegate is + // itself a plain `memcode run` job, not another Executive, so depth never + // grows past 1 today; the field exists so ValidateDelegation's depth check + // means something even before nested Personal-Agent delegation exists. + DelegationDepth int +} + +type RunOutcome struct { + RunID string `json:"run_id"` + Status string `json:"status"` + Report string `json:"report"` + NextWakeAt *time.Time `json:"next_wake_at,omitempty"` + InteractionID string `json:"interaction_id,omitempty"` +} + +func strProp(desc string) map[string]any { + return map[string]any{"type": "string", "description": desc} +} +func obj(props map[string]any, required ...string) map[string]any { + m := map[string]any{"type": "object", "properties": props} + if len(required) > 0 { + m["required"] = required + } + return m +} + +var executiveToolDefs = []wire.ToolDef{ + { + Name: "subgoal_update", + Description: "Create or update an intermediate subgoal beneath the objective. Subgoals are agent-generated planning data and never expand authority. Provide id, description, status (pending|active|done|abandoned|blocked), priority, rationale.", + InputSchema: obj(map[string]any{ + "id": strProp("stable subgoal id, e.g. sg-1"), + "description": strProp("what this subgoal achieves"), + "status": strProp("pending|active|done|abandoned|blocked"), + "priority": map[string]any{"type": "integer", "description": "higher runs first"}, + "rationale": strProp("why this subgoal exists"), + }, "id", "description", "status"), + }, + { + Name: "remember", + Description: "Append something durable to this agent's memory (memory.md in its home), so it is known on every future wake and never has to be asked again. Use it for what you learn about the user and their environment — a preference, a constraint, an answer they gave you. Write one short, self-contained sentence in plain language, including where it came from when that matters (\"Tim said ...\", \"the resume lists ...\").", + InputSchema: obj(map[string]any{ + "note": strProp("one durable sentence to remember, e.g. \"Tim is a US citizen and needs no visa sponsorship (he confirmed this directly).\""), + }, "note"), + }, + { + Name: "read_file", + Description: "Read a file inside an approved filesystem grant (observe).", + InputSchema: obj(map[string]any{"path": strProp("absolute path within a granted filesystem root")}, "path"), + }, + { + Name: "write_file", + Description: "Write a file inside an approved filesystem grant (local_mutation). Journaled.", + InputSchema: obj(map[string]any{ + "path": strProp("absolute path within a granted writable root"), + "content": strProp("file contents"), + }, "path", "content"), + }, + { + Name: "schedule_wake", + Description: "Schedule the next bounded wake for this objective (interval like 30m, or an RFC3339 time). The agent never runs continuously; it must schedule its next wake.", + InputSchema: obj(map[string]any{ + "after": strProp("Go duration from now, e.g. 30m"), + "at": strProp("RFC3339 timestamp"), + "reason": strProp("why the next wake is needed"), + }), + }, + { + Name: "ask_user", + Description: "Ask the human a question and suspend durably until answered. The whole wake pauses; answer resumes it with exact continuation. Use for missing info, approval, or clarification.", + InputSchema: obj(map[string]any{ + "question": strProp("the question for the human"), + "context": strProp("what the human needs to know to answer"), + }, "question"), + }, + { + Name: "report", + Description: "End this wake with a status report and mark the run completed. Include what was done and the next planned step.", + InputSchema: obj(map[string]any{"summary": strProp("concise status report")}, "summary"), + }, + { + Name: "delegate", + Description: "Delegate a bounded task to a scoped worker — a full memcode agent (browser, MCP, shell, filesystem, skills — whatever toolsets you name) running as a detached job, NOT another executive. Use this whenever the objective needs a real capability outside this executive's own 7 tools (browsing a site, calling an MCP tool, running a shell command, editing code). The worker's toolset/consequences must be a subset of this agent's own approved policy — expanding authority is rejected. This wake ends without the result; call check_delegate on a later wake (schedule_wake first) to collect it.", + InputSchema: obj(map[string]any{ + "task": strProp("the bounded task for the worker, self-contained (the worker has no access to this conversation)"), + "expected_output": strProp("what a successful result looks like"), + "completion_condition": strProp("how the worker knows it's done"), + "toolsets": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "toolsets/tools the worker may use, e.g. [\"browser\"], [\"mcp:gmail\"] — must be a subset of this agent's approved allowed_tools. \"browser\" defaults to the user's OWN already-running, already-logged-in Chrome (existing sessions: Gmail, LinkedIn, etc.) — use \"browser:ephemeral\" instead only when the task genuinely wants a fresh, logged-out profile."}, + "consequences": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "consequence classes this task may incur, e.g. [\"observe\"], [\"external_effect\"] — must be a subset of this agent's approved consequence classes"}, + "max_seconds": map[string]any{"type": "integer", "description": "wall-clock budget for the worker"}, + }, "task", "expected_output", "completion_condition"), + }, + { + Name: "check_delegate", + Description: "Check on a job started by delegate. Returns its status (running, done, failed, stopped) and, once finished, its result text. Call this on the wake after you delegated, not the same wake.", + InputSchema: obj(map[string]any{"job_id": strProp("the job id returned by delegate")}, "job_id"), + }, +} + +func toolNames(defs []wire.ToolDef) []string { + out := make([]string, len(defs)) + for i, d := range defs { + out[i] = d.Name + } + return out +} + +// RunOnce executes a single bounded wake for the agent's primary objective. +// It fails closed: no approved policy, inactive objective, or an expired/revoked +// policy all block consequential work before any LLM call is made. +func (e *Executive) RunOnce(ctx context.Context) (RunOutcome, error) { + if e.Now == nil { + e.Now = time.Now + } + if e.MaxSteps <= 0 { + e.MaxSteps = 8 + } + now := e.now().UTC() + + // The objective is configuration (gateway.yaml), not database state — one + // source, edited by a human, hot-reloaded. The store keeps only what accrues + // from running: subgoals, actions, policies, interactions. + if strings.TrimSpace(e.Objective) == "" { + return RunOutcome{Status: "blocked", Report: "no objective set for this agent"}, nil + } + pol, hasPol, err := e.Store.ApprovedPolicy(ctx, "primary") + if err != nil { + return RunOutcome{}, err + } + if !hasPol { + return RunOutcome{Status: "blocked", Report: "no approved policy — consequential work is blocked until one is approved (gw_policy)"}, nil + } + var policyDoc DelegationPolicy + if err := json.Unmarshal(pol.Document, &policyDoc); err != nil { + return RunOutcome{}, fmt.Errorf("approved policy is corrupt: %w", err) + } + if !policyDoc.AllowsConsequence(Observe, now) { + return RunOutcome{Status: "blocked", Report: "approved policy is expired or revoked"}, nil + } + if err := ValidateExecutiveBudget(nonzero(policyDoc.MaxSeconds, 600), nonzero(policyDoc.MaxActionsPerPeriod, e.MaxSteps), policyDoc.MaxDelegationDepth); err != nil { + return RunOutcome{}, err + } + + // Filter tools to the policy's allowlist (deny wins; empty = all non-suspending core). + tools := e.allowedTools(policyDoc) + if len(tools) == 0 { + return RunOutcome{Status: "blocked", Report: "policy allows no executive tools"}, nil + } + + runID := fmt.Sprintf("run-%d", now.UnixNano()) + env, _ := json.Marshal(map[string]any{"agent": e.AgentID, "policy_hash": pol.Hash, "tools": toolNames(tools)}) + if err := e.Store.CreateRun(ctx, Run{ID: runID, ObjectiveID: "primary", Status: "running", Envelope: env}); err != nil { + return RunOutcome{}, err + } + + // Build the opening user turn; the durable objective/subgoal/fact state rides + // as the doctrine `state` fact (Mode: personal). + msgs := []wire.Message{{Role: "user", Blocks: []wire.Block{wire.TextBlock("Advance the objective with one bounded step. Call report when done, schedule_wake to set the next wake, or ask_user if you need the human.")}}} + out := e.loop(ctx, runID, policyDoc, pol.Hash, msgs, tools) + _ = e.Store.UpdateRunStatus(ctx, runID, out.Status, json.RawMessage(fmt.Sprintf(`{"report":%q}`, out.Report))) + return out, nil +} + +// loop runs the bounded tool-call loop over a message history. Shared by +// RunOnce and resume: resume re-enters with the saved transcript plus the +// answered tool_result appended. +func (e *Executive) loop(ctx context.Context, runID string, policyDoc DelegationPolicy, policyHash string, msgs []wire.Message, tools []wire.ToolDef) RunOutcome { + if e.MaxSteps <= 0 { + e.MaxSteps = 8 + } + var out RunOutcome + out.RunID = runID + out.Status = "completed" + for step := 0; step < e.MaxSteps; step++ { + resp, err := e.Runner.Complete(ctx, llm.MainLoop, wire.Request{ + Mode: "autonomous", + Facts: map[string]string{"state": e.stateSummary(policyDoc)}, + Messages: msgs, + Tools: tools, + }) + if err != nil { + out.Status = "failed" + out.Report = "model error: " + err.Error() + _ = e.Store.UpdateRunStatus(ctx, runID, "failed", json.RawMessage(fmt.Sprintf(`{%q:%q}`, "error", out.Report))) + return out + } + assistant := wire.Message{Role: "assistant", Blocks: resp.Blocks} + msgs = append(msgs, assistant) + + // Partition tool calls; detect a suspension (must be sole tool use). + var calls []wire.Block + var text strings.Builder + for _, b := range resp.Blocks { + if b.Type == "tool_use" { + calls = append(calls, b) + } + if b.Type == "text" { + text.WriteString(b.Text) + } + } + if resp.StopReason != "tool_use" || len(calls) == 0 { + // Model ended the turn with text. + out.Report = strings.TrimSpace(text.String()) + break + } + // Handle each tool call, collecting results. + var results []wire.Block + suspended := false + reported := false + for _, c := range calls { + res, susp, err := e.execTool(ctx, runID, policyDoc, policyHash, c, msgs) + if err != nil { + results = append(results, wire.Block{Type: "tool_result", ToolUseID: c.ID, Content: "error: " + err.Error(), IsError: true}) + continue + } + if susp != nil { + // Suspension must be the sole tool use; persist exact continuation. + if len(calls) != 1 { + results = append(results, wire.Block{Type: "tool_result", ToolUseID: c.ID, Content: "ask_user must be the only tool call in a response", IsError: true}) + continue + } + out.Status = "suspended" + out.InteractionID = susp.ID + out.Report = "waiting for human: " + susp.Question + suspended = true + break + } + if res.report != "" { + out.Report = res.report + reported = true + } + if res.nextWake != nil { + out.NextWakeAt = res.nextWake + } + results = append(results, wire.Block{Type: "tool_result", ToolUseID: c.ID, Content: res.content}) + } + if suspended { + _ = e.Store.UpdateRunStatus(ctx, runID, "waiting", json.RawMessage(fmt.Sprintf(`{"interaction_id":%q}`, out.InteractionID))) + return out + } + // report ends the wake: its summary is the run's report. + if reported { + break + } + msgs = append(msgs, wire.Message{Role: "user", Blocks: results}) + } + return out +} + +func nonzero(v, d int) int { + if v == 0 { + return d + } + return v +} + +func (e *Executive) now() time.Time { + if e.Now != nil { + return e.Now() + } + return time.Now() +} + +// allowedTools filters the executive toolset by policy. The policy's +// AllowedTools is the primary gate: when non-empty, only those tools are +// exposed. Consequence classes are a second gate — a mutation/external tool is +// exposed only if both listed AND its consequence class is allowed. Observe/ +// planning tools still require their (implicit) class to pass. +func (e *Executive) allowedTools(p DelegationPolicy) []wire.ToolDef { + now := e.now().UTC() + allowed := map[string]bool{} + restrictByName := len(p.AllowedTools) > 0 + for _, t := range p.AllowedTools { + allowed[t] = true + } + // consequence requirement per tool + need := map[string]ConsequenceClass{ + "read_file": Observe, + "write_file": LocalMutation, + } + var out []wire.ToolDef + for _, d := range executiveToolDefs { + if restrictByName && !allowed[d.Name] { + continue // not in the policy's allowlist + } + if cons, ok := need[d.Name]; ok && !p.AllowsConsequence(cons, now) { + continue // consequence class not granted + } + out = append(out, d) + } + return out +} + +// stateSummary renders the durable objective/subgoal/fact state as the doctrine +// `state` fact for the personal mode. It is data, not prompt prose. +func (e *Executive) stateSummary(p DelegationPolicy) string { + var b strings.Builder + fmt.Fprintf(&b, "Objective: %s\n", e.Objective) + fmt.Fprintf(&b, "Policy consequence classes: %v; delegation depth %d.\n", p.ConsequenceClasses, p.MaxDelegationDepth) + if subs, err := e.Store.ListSubgoals(context.Background(), "primary"); err == nil && len(subs) > 0 { + b.WriteString("Current subgoals:\n") + for _, g := range subs { + fmt.Fprintf(&b, " - [%s] %s (%s)\n", g.Status, g.Description, g.ID) + } + } + if mem := ReadMemory(e.Home); mem != "" { + b.WriteString("What you know (memory.md):\n") + b.WriteString(mem) + if !strings.HasSuffix(mem, "\n") { + b.WriteString("\n") + } + } + return b.String() +} + +type toolResult struct { + content string + report string + nextWake *time.Time +} + +type suspensionInfo struct { + ID, Question string +} + +// execTool runs one executive tool under the policy. It returns a result, or a +// suspension if the tool is ask_user. +func (e *Executive) execTool(ctx context.Context, runID string, p DelegationPolicy, policyHash string, call wire.Block, msgs []wire.Message) (toolResult, *suspensionInfo, error) { + now := e.now().UTC() + journaling := func(kind, target string, cons ConsequenceClass, req json.RawMessage) (string, error) { + actID := fmt.Sprintf("act-%d", now.UnixNano()) + _, fresh, err := e.Store.ReserveAction(ctx, ActionIntent{ + ID: actID, ObjectiveID: "primary", RunID: runID, Kind: kind, Target: target, + Consequence: cons, PolicyHash: policyHash, Request: req, + }) + if err != nil { + return "", err + } + if !fresh { + return "", fmt.Errorf("duplicate action rejected") + } + return actID, e.Store.MarkActionRunning(ctx, actID) + } + + switch call.Name { + case "subgoal_update": + var in struct { + ID, Description, Status, Rationale string + Priority int + } + if err := json.Unmarshal(call.Input, &in); err != nil { + return toolResult{}, nil, err + } + err := e.Store.UpsertSubgoal(ctx, Subgoal{ID: in.ID, ObjectiveID: "primary", Description: in.Description, Status: in.Status, Priority: in.Priority, Rationale: in.Rationale}) + if err != nil { + return toolResult{}, nil, err + } + return toolResult{content: "subgoal " + in.ID + " recorded"}, nil, nil + + case "remember": + var in struct { + Note string `json:"note"` + } + if err := json.Unmarshal(call.Input, &in); err != nil { + return toolResult{}, nil, err + } + if err := AppendMemory(e.Home, in.Note); err != nil { + return toolResult{}, nil, err + } + return toolResult{content: "remembered"}, nil, nil + + case "read_file": + var in struct{ Path string } + if err := json.Unmarshal(call.Input, &in); err != nil { + return toolResult{}, nil, err + } + data, err := e.readGranted(in.Path) + if err != nil { + return toolResult{}, nil, err + } + return toolResult{content: data}, nil, nil + + case "write_file": + var in struct{ Path, Content string } + if err := json.Unmarshal(call.Input, &in); err != nil { + return toolResult{}, nil, err + } + if !p.AllowsConsequence(LocalMutation, now) { + return toolResult{}, nil, fmt.Errorf("policy does not allow local_mutation") + } + actID, err := journaling("write_file", in.Path, LocalMutation, call.Input) + if err != nil { + return toolResult{}, nil, err + } + if err := e.writeGranted(in.Path, in.Content); err != nil { + _ = e.Store.CompleteAction(ctx, actID, ActionFailed, json.RawMessage(fmt.Sprintf(`{%q:%q}`, "error", err.Error())), nil) + return toolResult{}, nil, err + } + _ = e.Store.CompleteAction(ctx, actID, ActionSucceeded, nil, nil) + return toolResult{content: "wrote " + in.Path}, nil, nil + + case "schedule_wake": + var in struct{ After, At, Reason string } + if err := json.Unmarshal(call.Input, &in); err != nil { + return toolResult{}, nil, err + } + var next time.Time + if in.After != "" { + d, err := time.ParseDuration(in.After) + if err != nil { + return toolResult{}, nil, err + } + next = now.Add(d) + } else if in.At != "" { + t, err := time.Parse(time.RFC3339, in.At) + if err != nil { + return toolResult{}, nil, err + } + next = t + } else { + return toolResult{}, nil, fmt.Errorf("schedule_wake needs after or at") + } + tid := fmt.Sprintf("wake-%d", next.Unix()) + if err := e.Store.CreateTrigger(ctx, Trigger{ID: tid, ObjectiveID: "primary", Kind: "next_wake", Spec: next.Format(time.RFC3339), NextDueAt: &next}); err != nil { + return toolResult{}, nil, err + } + return toolResult{content: "next wake at " + next.Format(time.RFC3339), nextWake: &next}, nil, nil + + case "delegate": + var in struct { + Task string `json:"task"` + ExpectedOutput string `json:"expected_output"` + CompletionCondition string `json:"completion_condition"` + Toolsets []string `json:"toolsets"` + Consequences []string `json:"consequences"` + MaxSeconds int `json:"max_seconds"` + } + if err := json.Unmarshal(call.Input, &in); err != nil { + return toolResult{}, nil, err + } + var consequences []ConsequenceClass + for _, c := range in.Consequences { + cls := ConsequenceClass(c) + if !p.AllowsConsequence(cls, now) { + return toolResult{}, nil, fmt.Errorf("policy does not allow delegating consequence %q", c) + } + consequences = append(consequences, cls) + } + browserSession := browserModeFor(in.Toolsets) + env := ExecutionEnvelope{ + Task: in.Task, ExpectedOutput: in.ExpectedOutput, CompletionCondition: in.CompletionCondition, + Toolsets: in.Toolsets, Consequences: consequences, ParentRunID: runID, + Budgets: jobs.ExecutionBudgets{MaxSeconds: in.MaxSeconds}, + DelegationDepth: e.DelegationDepth + 1, + AllowDelegation: false, // the worker is a plain memcode run, not another executive — it cannot delegate further + BrowserSession: browserSession, + } + if err := ValidateDelegation(p, env); err != nil { + return toolResult{}, nil, err + } + if browserSession == BrowserExistingChrome { + // Fail closed BEFORE spawning anything: a worker that can't reach + // the broker must never silently run with ephemeral (logged-out) + // Chrome instead — that would complete "successfully" while doing + // something other than what was asked and authorized. + sock, err := broker.SocketPath() + if err != nil || !broker.NewClient(sock).Reachable() { + return toolResult{}, nil, fmt.Errorf("existing-Chrome is not available (gateway not running, or existing-Chrome not set up — check with gw_browser) — refusing to fall back to ephemeral Chrome") + } + } + actID, err := journaling("delegate", in.Task, delegateConsequence(consequences), call.Input) + if err != nil { + return toolResult{}, nil, err + } + workDir, err := e.delegateRoot() + if err != nil { + _ = e.Store.CompleteAction(ctx, actID, ActionFailed, json.RawMessage(fmt.Sprintf(`{%q:%q}`, "error", err.Error())), nil) + return toolResult{}, nil, err + } + if _, err := PrepareRunDirectory(e.Home, runID, env); err != nil { + _ = e.Store.CompleteAction(ctx, actID, ActionFailed, json.RawMessage(fmt.Sprintf(`{%q:%q}`, "error", err.Error())), nil) + return toolResult{}, nil, err + } + job, err := jobs.SpawnWithSpec(jobs.SpawnSpec{ + Root: workDir, + Task: fmt.Sprintf("%s\n\nExpected output: %s\nDone when: %s", in.Task, in.ExpectedOutput, in.CompletionCondition), + Mode: delegateMode(consequences), + ToolPolicy: jobs.ToolPolicy{Allowed: in.Toolsets}, + Budgets: env.Budgets, + AgentID: e.AgentID, ObjectiveID: "primary", RunID: runID, ParentRunID: runID, + PolicyHash: policyHash, BrowserMode: browserSession, ReportBack: true, + }) + if err != nil { + _ = e.Store.CompleteAction(ctx, actID, ActionFailed, json.RawMessage(fmt.Sprintf(`{%q:%q}`, "error", err.Error())), nil) + return toolResult{}, nil, err + } + // Link the job to its action so check_delegate can close it out later: + // RunOnce is one bounded wake, so the worker's result necessarily + // arrives on a subsequent wake, not this one. + _ = e.Store.LinkActionJob(ctx, actID, job.ID) + return toolResult{content: fmt.Sprintf("delegated as job %s — call check_delegate on a later wake to collect the result", job.ID)}, nil, nil + + case "check_delegate": + var in struct { + JobID string `json:"job_id"` + } + if err := json.Unmarshal(call.Input, &in); err != nil { + return toolResult{}, nil, err + } + workDir, err := e.delegateRoot() + if err != nil { + return toolResult{}, nil, err + } + job, err := jobs.Get(workDir, in.JobID) + if err != nil { + return toolResult{}, nil, fmt.Errorf("no delegated job %q: %w", in.JobID, err) + } + if job.Status == jobs.StatusRunning || job.Status == jobs.StatusWaiting { + return toolResult{content: fmt.Sprintf("job %s still %s", job.ID, job.Status)}, nil, nil + } + actID, _ := e.Store.ActionForJob(ctx, in.JobID) + status, result := ActionSucceeded, json.RawMessage(fmt.Sprintf(`{"result":%q}`, job.Result)) + if job.Status != jobs.StatusDone || job.ExitCode != 0 { + status, result = ActionFailed, json.RawMessage(fmt.Sprintf(`{"status":%q,"exit_code":%d}`, job.Status, job.ExitCode)) + } + if actID != "" { + _ = e.Store.CompleteAction(ctx, actID, status, result, nil) + } + return toolResult{content: fmt.Sprintf("job %s %s: %s", job.ID, job.Status, job.Result)}, nil, nil + + case "ask_user": + var in struct{ Question, Context string } + if err := json.Unmarshal(call.Input, &in); err != nil { + return toolResult{}, nil, err + } + interactionID := fmt.Sprintf("int-%d", now.UnixNano()) + // Persist the durable interaction (fails if DB write fails → no suspension). + if err := e.Store.InsertInteraction(ctx, Interaction{ + ID: interactionID, AgentID: e.AgentID, ObjectiveID: "primary", RunID: runID, + Kind: "question", Question: in.Question, Context: in.Context, ToolUseID: call.ID, Status: "pending", + }); err != nil { + return toolResult{}, nil, fmt.Errorf("could not persist interaction: %w", err) + } + // Persist the exact continuation for resume (transcript + tool_use_id). + assistant := msgs[len(msgs)-1] + if err := writeSuspension(e.Home, runID, interactionID, call, assistant, msgs); err != nil { + return toolResult{}, nil, fmt.Errorf("could not persist continuation: %w", err) + } + return toolResult{}, &suspensionInfo{ID: interactionID, Question: in.Question}, nil + + case "report": + var in struct{ Summary string } + if err := json.Unmarshal(call.Input, &in); err != nil { + return toolResult{}, nil, err + } + return toolResult{content: "reported", report: in.Summary}, nil, nil + + default: + return toolResult{}, nil, fmt.Errorf("unknown executive tool %q", call.Name) + } +} + +// delegateRoot is the project root a delegated worker runs in: the agent's own +// workspace (the same directory write_file treats as always-writable). A +// worker that needs a different project is future work — for now every +// delegated job is rooted here, and jobs.Get must be called against the same +// root to find it again. +func (e *Executive) delegateRoot() (string, error) { + dir := filepath.Join(e.Home, "workspace") + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", err + } + return dir, nil +} + +// delegateConsequence reports the highest-stakes consequence class in a +// delegated task, for the action journal entry (ReserveAction needs exactly +// one). Order matches the severity ExecutionEnvelope.Consequences is checked +// in: an empty request journals as pure observation. +func delegateConsequence(cs []ConsequenceClass) ConsequenceClass { + order := []ConsequenceClass{Destructive, LegalAttestation, Financial, ExternalRepresentation, ExternalEffect, LocalMutation, Observe} + have := map[ConsequenceClass]bool{} + for _, c := range cs { + have[c] = true + } + for _, c := range order { + if have[c] { + return c + } + } + return Observe +} + +// delegateMode picks the worker's permission mode from what it's authorized to +// do. Detached jobs have no human to answer approval prompts (SetNoApprover), +// so --ask is the fail-closed choice for anything beyond safe local mutation: +// the worker simply can't perform an action requiring approval, rather than +// silently getting more authority than the policy actually granted it. +func delegateMode(cs []ConsequenceClass) string { + for _, c := range cs { + if c != Observe && c != LocalMutation { + return "ask" + } + } + return "auto" +} + +// browserModeFor reports the jobs.SpawnSpec.BrowserMode for a requested +// toolset list. A bare "browser" defaults to BrowserExistingChrome — the +// user's own already-running, already-logged-in Chrome, reached through the +// gateway-owned broker (see internal/browser/broker) — because a Personal +// Agent's whole point is acting as the user, and most useful browser work +// (Gmail, LinkedIn, an ATS, an internal dashboard) requires being signed in. +// "browser:ephemeral" is the explicit opt-down to a fresh, logged-out +// profile, for tasks that genuinely don't want the user's session (e.g. +// visiting a site anonymously). See docs/design/personal-agents.md "Browser +// broker trust boundary". +func browserModeFor(toolsets []string) string { + for _, t := range toolsets { + switch t { + case "browser", "browser:existing_chrome": + return BrowserExistingChrome + case "browser:ephemeral": + return BrowserEphemeral + } + } + return "" +} + +// readGranted reads a file only if it lies within an approved filesystem grant. +func (e *Executive) readGranted(path string) (string, error) { + res, err := e.Store.ListResources(context.Background(), "primary") + if err != nil { + return "", err + } + // The agent's own home is always readable. + if PathWithinGrant(path, e.Home) { + b, err := os.ReadFile(path) + return string(b), err + } + for _, r := range res { + if r.Type == "filesystem" && r.Status == "active" && PathWithinGrant(path, r.Locator) { + b, err := os.ReadFile(path) + return string(b), err + } + } + return "", fmt.Errorf("path %s is not within an approved filesystem grant", path) +} + +func (e *Executive) writeGranted(path, content string) error { + res, err := e.Store.ListResources(context.Background(), "primary") + if err != nil { + return err + } + writable := func(r Resource) bool { + return r.Type == "filesystem" && r.Status == "active" && (r.AccessMode == "write" || r.AccessMode == "admin") && PathWithinGrant(path, r.Locator) + } + // The generated workspace is always writable (agent-owned). + if PathWithinGrant(path, filepath.Join(e.Home, "workspace")) { + return os.WriteFile(path, []byte(content), 0o600) + } + for _, r := range res { + if writable(r) { + return os.WriteFile(path, []byte(content), 0o600) + } + } + return fmt.Errorf("path %s is not within a writable approved filesystem grant", path) +} + +// suspensionDir is where this run's continuations live. The executive keeps no +// transcript between wakes, so its continuations sit beside the run rather than +// under a session directory (see continuation.SessionDir for the interactive +// layout). +func suspensionDir(home, runID string) string { + return filepath.Join(home, "runs", runID) +} + +// writeSuspension persists the exact continuation for an ask_user suspension. +// It stores the full message transcript (Messages) because a wake rebuilds its +// context from durable state and has no transcript to append to on resume. +func writeSuspension(home, runID, interactionID string, call wire.Block, assistant wire.Message, msgs []wire.Message) error { + return continuation.Save(suspensionDir(home, runID), continuation.Suspension{ + RunID: runID, InteractionID: interactionID, + ToolUseID: call.ID, ToolName: call.Name, ToolInput: json.RawMessage(call.Input), + Assistant: assistant, Messages: msgs, + }) +} + +// ResumeSuspended continues a suspended run after its interaction is answered. +// It loads the saved transcript, appends the exact tool_result for the suspended +// tool_use_id, then re-enters the bounded loop so the model actually continues — +// no replay of completed actions, no fabricated user turn. It marks the +// continuation resolved ONLY after the resumed run finishes, so a failure leaves +// the interaction retryable. +func (e *Executive) ResumeSuspended(ctx context.Context, in Interaction, answer string) (RunOutcome, error) { + dir := suspensionDir(e.Home, in.RunID) + s, err := continuation.Load(dir, in.ID) + if err != nil { + return RunOutcome{}, fmt.Errorf("no resumable continuation for interaction %q: %w", in.ID, err) + } + // Re-load the approved policy (it may have narrowed since suspension). + pol, hasPol, err := e.Store.ApprovedPolicy(ctx, "primary") + if err != nil { + return RunOutcome{}, err + } + if !hasPol { + return RunOutcome{}, fmt.Errorf("policy was revoked while suspended — cannot resume") + } + var policyDoc DelegationPolicy + if err := json.Unmarshal(pol.Document, &policyDoc); err != nil { + return RunOutcome{}, err + } + tools := e.allowedTools(policyDoc) + + // Rebuild the transcript with the exact tool result matching the suspended + // tool_use_id. Not marked resolved yet — see below. + msgs, err := s.ResumeMessages(wire.Block{Type: "tool_result", ToolUseID: s.ToolUseID, Content: answer}) + if err != nil { + return RunOutcome{}, err + } + + out := e.loop(ctx, in.RunID, policyDoc, pol.Hash, msgs, tools) + + // Mark the continuation resolved once the answer has been consumed: either the + // run reached a terminal state, or it re-suspended on a new interaction (whose + // own continuation carries the appended answer forward). A hard resume error + // leaves it unresolved on purpose, so the answer can be given again. + if out.Status == "completed" || out.Status == "failed" || out.Status == "suspended" { + _ = continuation.MarkResolved(dir, in.ID) + _ = e.Store.UpdateRunStatus(ctx, in.RunID, out.Status, json.RawMessage(fmt.Sprintf(`{"report":%q}`, out.Report))) + } + return out, nil +} diff --git a/internal/agent/autonomy/runner_exec_test.go b/internal/agent/autonomy/runner_exec_test.go new file mode 100644 index 0000000..d972be6 --- /dev/null +++ b/internal/agent/autonomy/runner_exec_test.go @@ -0,0 +1,499 @@ +package autonomy + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/memcode-ai/memcode/internal/agent/continuation" + "github.com/memcode-ai/memcode/internal/browser/broker" + "github.com/memcode-ai/memcode/internal/jobs" + "github.com/memcode-ai/memcode/internal/llm" + "github.com/memcode-ai/memcode/internal/provider" + "github.com/memcode-ai/memcode/internal/wire" +) + +// fakeProv is a scripted ModelProvider driving the executive loop deterministically. +type fakeProv struct { + steps []wire.Response + calls int +} + +func (f *fakeProv) Complete(ctx context.Context, r wire.Request) (wire.Response, error) { + if f.calls >= len(f.steps) { + return wire.Response{StopReason: "end_turn", Blocks: []wire.Block{wire.TextBlock("done")}}, nil + } + resp := f.steps[f.calls] + f.calls++ + return resp, nil +} +func (f *fakeProv) Endpoint() (provider.Endpoint, bool) { return provider.Endpoint{}, false } + +func toolUse(id, name string, input any) wire.Block { + b, _ := json.Marshal(input) + return wire.Block{Type: "tool_use", ID: id, Name: name, Input: b} +} + +// testObjective stands in for gwconfig.Agent.Objective — the executive now +// reads its goal from configuration rather than the store. +const testObjective = "Keep dependencies fresh" + +func newTestExecutive(t *testing.T, prov provider.ModelProvider) (*Executive, *Store, string) { + t.Helper() + ctx := context.Background() + home := t.TempDir() + st, err := Open(ctx, home) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + if err := st.CreateObjective(ctx, Objective{ID: "primary", Description: "Keep dependencies fresh", SuccessCriteria: "no outdated deps", Status: "active"}); err != nil { + t.Fatal(err) + } + ex := &Executive{Store: st, Home: home, AgentID: "tester", Objective: testObjective, Runner: llm.NewRunner(prov)} + return ex, st, home +} + +func approveTestPolicy(t *testing.T, st *Store) { + t.Helper() + ctx := context.Background() + doc := DelegationPolicy{ObjectiveScope: "primary", ConsequenceClasses: []ConsequenceClass{Observe, LocalMutation}, MaxSeconds: 300, MaxActionsPerPeriod: 8} + canon, hash, err := CanonicalPolicy(doc) + if err != nil { + t.Fatal(err) + } + if err := st.InsertPolicy(ctx, Policy{ID: "p1", ObjectiveID: "primary", Version: 1, Document: canon, Hash: hash, Status: "draft"}); err != nil { + t.Fatal(err) + } + if err := st.ApprovePolicy(ctx, hash); err != nil { + t.Fatal(err) + } +} + +func TestExecutiveBlocksWithoutPolicy(t *testing.T) { + prov := &fakeProv{} + ex, st, _ := newTestExecutive(t, prov) + out, err := ex.RunOnce(context.Background()) + if err != nil { + t.Fatal(err) + } + if out.Status != "blocked" || !strings.Contains(out.Report, "no approved policy") { + t.Fatalf("expected blocked, got %+v", out) + } + if prov.calls != 0 { + t.Fatal("LLM was called despite missing policy — fail-closed violated") + } + // No run should have been created. + runs, _ := st.ListRuns(context.Background(), "primary", 10) + if len(runs) != 0 { + t.Fatalf("run created without policy: %v", runs) + } +} + +func TestExecutiveRunsAndJournals(t *testing.T) { + prov := &fakeProv{steps: []wire.Response{ + {StopReason: "tool_use", Blocks: []wire.Block{toolUse("t1", "subgoal_update", map[string]any{"id": "sg1", "description": "scan deps", "status": "active", "priority": 5})}}, + {StopReason: "tool_use", Blocks: []wire.Block{toolUse("t2", "remember", map[string]any{"note": "3 dependencies are outdated (observed by scanning)"})}}, + {StopReason: "tool_use", Blocks: []wire.Block{toolUse("t3", "report", map[string]any{"summary": "found 3 outdated deps"})}}, + }} + ex, st, home := newTestExecutive(t, prov) + approveTestPolicy(t, st) + out, err := ex.RunOnce(context.Background()) + if err != nil { + t.Fatal(err) + } + if out.Status != "completed" { + t.Fatalf("status=%s report=%s", out.Status, out.Report) + } + if !strings.Contains(out.Report, "outdated") { + t.Fatalf("report=%q", out.Report) + } + // Subgoal recorded in the store; what it LEARNED went to memory.md, the + // same durable memory every memcode agent already has. + subs, _ := st.ListSubgoals(context.Background(), "primary") + if len(subs) != 1 || subs[0].Description != "scan deps" { + t.Fatalf("subgoals=%v", subs) + } + if mem := ReadMemory(home); !strings.Contains(mem, "3 dependencies are outdated") { + t.Fatalf("memory.md missing what the agent learned: %q", mem) + } + // Run recorded completed. + runs, _ := st.ListRuns(context.Background(), "primary", 10) + if len(runs) != 1 || runs[0].Status != "completed" { + t.Fatalf("runs=%+v", runs) + } +} + +func TestExecutiveSuspendsAndResumes(t *testing.T) { + prov := &fakeProv{steps: []wire.Response{ + {StopReason: "tool_use", Blocks: []wire.Block{toolUse("t1", "ask_user", map[string]any{"question": "proceed with upgrade?", "context": "3 deps outdated"})}}, + }} + ex, st, home := newTestExecutive(t, prov) + approveTestPolicy(t, st) + ctx := context.Background() + out, err := ex.RunOnce(ctx) + if err != nil { + t.Fatal(err) + } + if out.Status != "suspended" || out.InteractionID == "" { + t.Fatalf("out=%+v", out) + } + // Interaction is pending. + in, ok, err := st.GetInteraction(ctx, out.InteractionID) + if err != nil || !ok || in.Status != "pending" { + t.Fatalf("interaction=%+v ok=%v err=%v", in, ok, err) + } + // Inbox lists it. + pend, _ := st.PendingInteractions(ctx, "tester") + if len(pend) != 1 || pend[0].Question != "proceed with upgrade?" { + t.Fatalf("inbox=%v", pend) + } + // A loadable continuation exists (shared continuation package, not a + // bespoke file layout — assert through its API, not the filename). + if _, err := continuation.Load(suspensionDir(home, out.RunID), out.InteractionID); err != nil { + t.Fatalf("continuation missing or unloadable: %v", err) + } + // Resume actually re-runs the model with the answer; the resumed run then + // completes (fake provider returns report on the next turn). + prov2 := &fakeProv{steps: []wire.Response{ + {StopReason: "tool_use", Blocks: []wire.Block{toolUse("t9", "report", map[string]any{"summary": "upgraded after approval"})}}, + }} + ex2 := &Executive{Store: st, Home: home, AgentID: "tester", Objective: testObjective, Runner: llm.NewRunner(prov2)} + rout, err := ex2.ResumeSuspended(ctx, in, "yes, upgrade") + if err != nil { + t.Fatal(err) + } + if rout.Status != "completed" || !strings.Contains(rout.Report, "upgraded") { + t.Fatalf("resume outcome=%+v", rout) + } + if prov2.calls == 0 { + t.Fatal("resume never called the model — fake resume regression") + } + // Resolve after successful resume; double-resolve must fail. + if err := st.ResolveInteraction(ctx, out.InteractionID, "yes, upgrade"); err != nil { + t.Fatal(err) + } + if err := st.ResolveInteraction(ctx, out.InteractionID, "again"); err == nil { + t.Fatal("double resolve accepted") + } +} + +func TestExecutivePolicyDeniesWrite(t *testing.T) { + // Policy grants only Observe, no LocalMutation → write_file tool is filtered out. + prov := &fakeProv{steps: []wire.Response{ + {StopReason: "tool_use", Blocks: []wire.Block{toolUse("t1", "report", map[string]any{"summary": "observe only"})}}, + }} + ex, st, _ := newTestExecutive(t, prov) + ctx := context.Background() + doc := DelegationPolicy{ObjectiveScope: "primary", ConsequenceClasses: []ConsequenceClass{Observe}, MaxSeconds: 300, MaxActionsPerPeriod: 8} + canon, hash, _ := CanonicalPolicy(doc) + _ = st.InsertPolicy(ctx, Policy{ID: "p1", ObjectiveID: "primary", Version: 1, Document: canon, Hash: hash, Status: "draft"}) + _ = st.ApprovePolicy(ctx, hash) + // write_file must not be in the allowed tool list. + var policyDoc DelegationPolicy + _ = json.Unmarshal(canon, &policyDoc) + for _, d := range ex.allowedTools(policyDoc) { + if d.Name == "write_file" { + t.Fatal("write_file exposed without local_mutation") + } + } + if _, err := ex.RunOnce(ctx); err != nil { + t.Fatal(err) + } +} + +func TestPolicyApprovalMovesObjectiveActive(t *testing.T) { + ctx := context.Background() + st, err := Open(ctx, t.TempDir()) + if err != nil { + t.Fatal(err) + } + defer st.Close() + _ = st.CreateObjective(ctx, Objective{ID: "primary", Description: "x", Status: "draft"}) + approveTestPolicy(t, st) + if err := st.SetObjectiveStatus(ctx, "primary", "active"); err != nil { + t.Fatal(err) + } + p, ok, _ := st.ApprovedPolicy(ctx, "primary") + if !ok || p.Status != "approved" || p.ApprovedAt == nil { + t.Fatalf("policy=%+v", p) + } + // Second draft supersedes on approval. + doc2 := DelegationPolicy{ObjectiveScope: "primary", ConsequenceClasses: []ConsequenceClass{Observe}} + canon2, hash2, _ := CanonicalPolicy(doc2) + _ = st.InsertPolicy(ctx, Policy{ID: "p2", ObjectiveID: "primary", Version: 2, Document: canon2, Hash: hash2, Status: "draft"}) + _ = st.ApprovePolicy(ctx, hash2) + p, _, _ = st.ApprovedPolicy(ctx, "primary") + if p.Version != 2 { + t.Fatalf("expected v2 approved, got v%d", p.Version) + } +} + +// TestExecutiveDelegatesToWorker exercises delegate → check_delegate against a +// real (detached) jobs.SpawnWithSpec call. Under `go test`, the spawned "worker" +// is the test binary itself re-exec'd with flags that run zero tests (see +// jobs.isTestBinary), so it never calls jobs.Finish — the job settles at +// StatusStopped once the process exits, not StatusDone. That's enough to prove +// the wiring: an executive whose policy allows delegation actually launches a +// real, tracked, policy-scoped child process and can read its outcome back on +// a later wake, instead of failing closed or silently no-op'ing. +// TestExecutiveDelegateFailsClosedWithoutBroker proves the fail-closed +// requirement: delegating browser work when no gateway (and therefore no +// broker socket) is running must be REJECTED, not silently downgraded to +// ephemeral Chrome. No job may be spawned in this case at all. +// delegatedJobID finds the job spawned by the single journaled delegate action. +func delegatedJobID(t *testing.T, st *Store) string { + t.Helper() + ctx := context.Background() + actions, err := st.ListActions(ctx, "primary", 10) + if err != nil { + t.Fatal(err) + } + for _, a := range actions { + if a.Kind != "delegate" { + continue + } + // Round-trip the link the way check_delegate does. + for _, j := range []string{a.JobID} { + if j == "" { + continue + } + got, err := st.ActionForJob(ctx, j) + if err != nil || got != a.ID { + t.Fatalf("ActionForJob(%q) = %q, %v; want %q", j, got, err, a.ID) + } + return j + } + } + t.Fatalf("no delegate action with a linked job: %+v", actions) + return "" +} + +func TestExecutiveDelegateFailsClosedWithoutBroker(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) // guarantees no broker socket exists here + prov := &fakeProv{steps: []wire.Response{ + {StopReason: "tool_use", Blocks: []wire.Block{toolUse("t1", "delegate", map[string]any{ + "task": "check gmail", "expected_output": "a summary", "completion_condition": "read the inbox", + "toolsets": []string{"browser"}, "consequences": []string{"observe"}, + })}}, + {StopReason: "tool_use", Blocks: []wire.Block{toolUse("t2", "report", map[string]any{"summary": "done"})}}, + }} + ex, st, _ := newTestExecutive(t, prov) + ctx := context.Background() + doc := DelegationPolicy{ObjectiveScope: "primary", ConsequenceClasses: []ConsequenceClass{Observe}, MaxDelegationDepth: 1, MaxSeconds: 300, MaxActionsPerPeriod: 8} + canon, hash, err := CanonicalPolicy(doc) + if err != nil { + t.Fatal(err) + } + if err := st.InsertPolicy(ctx, Policy{ID: "p1", ObjectiveID: "primary", Version: 1, Document: canon, Hash: hash, Status: "draft"}); err != nil { + t.Fatal(err) + } + if err := st.ApprovePolicy(ctx, hash); err != nil { + t.Fatal(err) + } + + if _, err := ex.RunOnce(ctx); err != nil { + t.Fatal(err) + } + // The delegate tool call must have failed (surfaced as a tool_result error, + // not a spawned job) — no delegate action should have been journaled. + actions, err := st.ListActions(ctx, "primary", 10) + if err != nil { + t.Fatal(err) + } + for _, a := range actions { + if a.Kind == "delegate" { + t.Fatalf("expected no journaled delegate action without a broker, got %+v", a) + } + } +} + +// TestExecutiveDelegateUsesExistingChromeWhenBrokerRunning proves the other +// half of the fail-closed contract: when a broker IS reachable, a bare +// "browser" toolset resolves to existing_chrome (never silently downgrades to +// ephemeral), and that mode actually rides the spawned job's SpawnSpec. +func TestExecutiveDelegateUsesExistingChromeWhenBrokerRunning(t *testing.T) { + // Unix socket paths have a short OS limit (~104 bytes on macOS/BSD) — + // t.TempDir() nests deep enough to blow past it, so use a short root. + short, err := os.MkdirTemp("", "pab") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.RemoveAll(short) }) + t.Setenv("XDG_CONFIG_HOME", short) + sock, err := broker.SocketPath() + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(sock), 0o700); err != nil { + t.Fatal(err) + } + srv, err := broker.Serve(broker.New(), sock) + if err != nil { + t.Fatal(err) + } + defer srv.Close() + + prov := &fakeProv{steps: []wire.Response{ + {StopReason: "tool_use", Blocks: []wire.Block{toolUse("t1", "delegate", map[string]any{ + "task": "check gmail", "expected_output": "a summary", "completion_condition": "read the inbox", + "toolsets": []string{"browser"}, "consequences": []string{"observe"}, + })}}, + {StopReason: "tool_use", Blocks: []wire.Block{toolUse("t2", "report", map[string]any{"summary": "delegated"})}}, + }} + ex, st, _ := newTestExecutive(t, prov) + ctx := context.Background() + doc := DelegationPolicy{ObjectiveScope: "primary", ConsequenceClasses: []ConsequenceClass{Observe}, MaxDelegationDepth: 1, MaxSeconds: 300, MaxActionsPerPeriod: 8} + canon, hash, err := CanonicalPolicy(doc) + if err != nil { + t.Fatal(err) + } + if err := st.InsertPolicy(ctx, Policy{ID: "p1", ObjectiveID: "primary", Version: 1, Document: canon, Hash: hash, Status: "draft"}); err != nil { + t.Fatal(err) + } + if err := st.ApprovePolicy(ctx, hash); err != nil { + t.Fatal(err) + } + + if _, err := ex.RunOnce(ctx); err != nil { + t.Fatal(err) + } + // The job id comes back through the ACTION that spawned it — the journal + // owns that link now, rather than a key smuggled into semantic memory. + jobID := delegatedJobID(t, st) + root, err := ex.delegateRoot() + if err != nil { + t.Fatal(err) + } + job, err := jobs.Get(root, jobID) + if err != nil { + t.Fatal(err) + } + var spec jobs.SpawnSpec + if err := json.Unmarshal(job.ExecutionEnvelope, &spec); err != nil { + t.Fatal(err) + } + if spec.BrowserMode != BrowserExistingChrome { + t.Fatalf("expected BrowserMode=%q, got %q — \"browser\" must default to existing_chrome, not ephemeral", BrowserExistingChrome, spec.BrowserMode) + } +} + +func TestExecutiveDelegatesToWorker(t *testing.T) { + prov1 := &fakeProv{steps: []wire.Response{ + {StopReason: "tool_use", Blocks: []wire.Block{toolUse("t1", "delegate", map[string]any{ + "task": "look something up", "expected_output": "a fact", "completion_condition": "found it", + "consequences": []string{"observe"}, + })}}, + {StopReason: "tool_use", Blocks: []wire.Block{toolUse("t2", "report", map[string]any{"summary": "delegated"})}}, + }} + ex, st, home := newTestExecutive(t, prov1) + ctx := context.Background() + doc := DelegationPolicy{ObjectiveScope: "primary", ConsequenceClasses: []ConsequenceClass{Observe, LocalMutation}, MaxDelegationDepth: 1, MaxSeconds: 300, MaxActionsPerPeriod: 8} + canon, hash, err := CanonicalPolicy(doc) + if err != nil { + t.Fatal(err) + } + if err := st.InsertPolicy(ctx, Policy{ID: "p1", ObjectiveID: "primary", Version: 1, Document: canon, Hash: hash, Status: "draft"}); err != nil { + t.Fatal(err) + } + if err := st.ApprovePolicy(ctx, hash); err != nil { + t.Fatal(err) + } + + out, err := ex.RunOnce(ctx) + if err != nil { + t.Fatal(err) + } + if out.Status != "completed" { + t.Fatalf("status=%s report=%s", out.Status, out.Report) + } + + // The job id comes back through the ACTION that spawned it — the journal + // owns that link now, rather than a key smuggled into semantic memory. + jobID := delegatedJobID(t, st) + + actions, err := st.ListActions(ctx, "primary", 10) + if err != nil { + t.Fatal(err) + } + var delegateAction Action + for _, a := range actions { + if a.Kind == "delegate" { + delegateAction = a + } + } + if delegateAction.ID == "" || delegateAction.Status != "running" { + t.Fatalf("expected a running delegate action, got %+v", actions) + } + + // Wait for the detached (test-binary) child to exit before checking on it. + root, err := ex.delegateRoot() + if err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(10 * time.Second) + for { + job, err := jobs.Get(root, jobID) + if err != nil { + t.Fatal(err) + } + if job.Status != jobs.StatusRunning { + break + } + if time.Now().After(deadline) { + t.Fatalf("delegated job %s still running after 10s", jobID) + } + time.Sleep(50 * time.Millisecond) + } + + prov2 := &fakeProv{steps: []wire.Response{ + {StopReason: "tool_use", Blocks: []wire.Block{toolUse("t3", "check_delegate", map[string]any{"job_id": jobID})}}, + {StopReason: "tool_use", Blocks: []wire.Block{toolUse("t4", "report", map[string]any{"summary": "checked"})}}, + }} + ex2 := &Executive{Store: st, Home: home, AgentID: "tester", Objective: testObjective, Runner: llm.NewRunner(prov2)} + out2, err := ex2.RunOnce(ctx) + if err != nil { + t.Fatal(err) + } + if out2.Status != "completed" { + t.Fatalf("status=%s report=%s", out2.Status, out2.Report) + } + + actions, err = st.ListActions(ctx, "primary", 10) + if err != nil { + t.Fatal(err) + } + for _, a := range actions { + if a.Kind == "delegate" { + delegateAction = a + } + } + if delegateAction.Status == "running" { + t.Fatalf("delegate action still running after check_delegate: %+v", delegateAction) + } +} + +func TestTriggerWakeSchedulingViaTool(t *testing.T) { + later := time.Now().UTC().Add(30 * time.Minute) + prov := &fakeProv{steps: []wire.Response{ + {StopReason: "tool_use", Blocks: []wire.Block{toolUse("t1", "schedule_wake", map[string]any{"at": later.Format(time.RFC3339)})}}, + {StopReason: "end_turn", Blocks: []wire.Block{wire.TextBlock("scheduled")}}, + }} + ex, st, _ := newTestExecutive(t, prov) + approveTestPolicy(t, st) + out, err := ex.RunOnce(context.Background()) + if err != nil { + t.Fatal(err) + } + if out.NextWakeAt == nil { + t.Fatal("no next wake recorded") + } + trigs, _ := st.ListTriggers(context.Background()) + if len(trigs) != 1 || trigs[0].Kind != "next_wake" { + t.Fatalf("triggers=%v", trigs) + } +} diff --git a/internal/agent/autonomy/runner_test.go b/internal/agent/autonomy/runner_test.go new file mode 100644 index 0000000..6ce4a14 --- /dev/null +++ b/internal/agent/autonomy/runner_test.go @@ -0,0 +1,65 @@ +package autonomy + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestGeneratedWorkspaceCommitAndRollback(t *testing.T) { + home := t.TempDir() + root, err := InitializeGeneratedWorkspace(home) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "artifact.txt"), []byte("v1"), 0o600); err != nil { + t.Fatal(err) + } + if err := CommitGenerated(root, "v1"); err != nil { + t.Fatal(err) + } + cmd := exec.Command("git", "rev-parse", "HEAD") + cmd.Dir = root + out, err := cmd.Output() + if err != nil { + t.Fatal(err) + } + rev := strings.TrimSpace(string(out)) + if err := os.WriteFile(filepath.Join(root, "artifact.txt"), []byte("regression"), 0o600); err != nil { + t.Fatal(err) + } + if err := RollbackGenerated(root, rev); err != nil { + t.Fatal(err) + } + b, _ := os.ReadFile(filepath.Join(root, "artifact.txt")) + if string(b) != "v1" { + t.Fatalf("content=%q", b) + } +} +func TestRunnerScrubsEnvironmentStagesInputsAndLimitsOutput(t *testing.T) { + shell, err := exec.LookPath("sh") + if err != nil { + t.Skip("sh unavailable") + } + result, err := RunGenerated(context.Background(), RunSpec{Executable: shell, AllowedExecutables: []string{shell}, Args: []string{"-c", "cat input.txt; printf %s \"$SECRET\"; printf 123456789"}, Inputs: map[string][]byte{"input.txt": []byte("input")}, Timeout: time.Second, MaxOutputBytes: 8}) + if err != nil { + t.Fatal(err) + } + if strings.Contains(result.Stdout, "SECRET") || len(result.Stdout) > 8 || !strings.HasPrefix(result.Stdout, "input") { + t.Fatalf("stdout=%q", result.Stdout) + } +} +func TestRunnerDeniesAuthorityExpansionAndFailsClosed(t *testing.T) { + if _, err := RunGenerated(context.Background(), RunSpec{Executable: "sh", AllowedExecutables: []string{"python"}}); err == nil { + t.Fatal("unallowed executable accepted") + } + if !SandboxAvailable() { + if _, err := RunGenerated(context.Background(), RunSpec{Executable: "sh", AllowedExecutables: []string{"sh"}, RequireHardenedSandbox: true}); err == nil { + t.Fatal("missing hardened sandbox did not fail closed") + } + } +} diff --git a/internal/agent/autonomy/scheduler.go b/internal/agent/autonomy/scheduler.go new file mode 100644 index 0000000..a5d88c0 --- /dev/null +++ b/internal/agent/autonomy/scheduler.go @@ -0,0 +1,162 @@ +package autonomy + +import ( + "context" + "database/sql" + "fmt" + "time" +) + +type MissedRunPolicy string + +const ( + MissedSkip MissedRunPolicy = "skip" + MissedRunOnce MissedRunPolicy = "run_once" + MissedCatchUp MissedRunPolicy = "catch_up" +) + +// NextDue resolves when a trigger should next fire. +// +// The only kinds here are the ones an AGENT writes for itself from inside a run +// (schedule_wake: "come back in 45 minutes"), which are always a single future +// instant. Recurring cadence a HUMAN configures is not a trigger at all — it is +// an ordinary gateway schedule delivering to agent:, parsed and validated +// once by gwconfig (see ValidateScheduleSpec/BuildSchedule). +// +// That split is deliberate: this package used to carry its own interval/cron +// parsing, which meant two cron implementations in one binary and two places +// for scheduling rules to disagree. Adding kinds back here would rebuild the +// second scheduler — internal/guard's TestSingleCronParser fails if it happens. +func NextDue(kind, spec string, after time.Time) (time.Time, error) { + switch kind { + case "manual": + return time.Time{}, nil + case "one_shot", "next_wake": + return time.Parse(time.RFC3339, spec) + default: + return time.Time{}, fmt.Errorf("unknown wake kind %q (an agent's self-scheduled wake is one_shot or next_wake; recurring cadence belongs in gw_schedule)", kind) + } +} + +func (s *Store) CreateTrigger(ctx context.Context, t Trigger) error { + now := time.Now().UTC() + if t.CreatedAt.IsZero() { + t.CreatedAt = now + } + if t.UpdatedAt.IsZero() { + t.UpdatedAt = now + } + if t.Status == "" { + t.Status = "enabled" + } + if t.MissedRunPolicy == "" { + t.MissedRunPolicy = string(MissedSkip) + } + _, err := s.db.ExecContext(ctx, `INSERT INTO triggers(id,objective_id,kind,spec,missed_run_policy,status,next_due_at,last_fired_at,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)`, t.ID, t.ObjectiveID, t.Kind, t.Spec, t.MissedRunPolicy, t.Status, nullableTime(t.NextDueAt), nullableTime(t.LastFiredAt), stamp(t.CreatedAt), stamp(t.UpdatedAt)) + return err +} + +func (s *Store) ListTriggers(ctx context.Context) ([]Trigger, error) { + rows, err := s.db.QueryContext(ctx, `SELECT id,objective_id,kind,spec,missed_run_policy,status,next_due_at,last_fired_at,created_at,updated_at FROM triggers ORDER BY created_at`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Trigger + for rows.Next() { + var t Trigger + var next, last sql.NullString + var created, updated string + if err := rows.Scan(&t.ID, &t.ObjectiveID, &t.Kind, &t.Spec, &t.MissedRunPolicy, &t.Status, &next, &last, &created, &updated); err != nil { + return nil, err + } + t.CreatedAt, _ = time.Parse(time.RFC3339Nano, created) + t.UpdatedAt, _ = time.Parse(time.RFC3339Nano, updated) + if next.Valid { + v, _ := time.Parse(time.RFC3339Nano, next.String) + t.NextDueAt = &v + } + if last.Valid { + v, _ := time.Parse(time.RFC3339Nano, last.String) + t.LastFiredAt = &v + } + out = append(out, t) + } + return out, rows.Err() +} + +// DueTriggers returns only enabled triggers whose next_due_at has passed, +// filtered in SQL rather than pulling every trigger row (including completed +// ones) and filtering in Go on every poll. +func (s *Store) DueTriggers(ctx context.Context, now time.Time) ([]Trigger, error) { + rows, err := s.db.QueryContext(ctx, `SELECT id,objective_id,kind,spec,missed_run_policy,status,next_due_at,last_fired_at,created_at,updated_at FROM triggers WHERE status='enabled' AND next_due_at IS NOT NULL AND next_due_at<=? ORDER BY created_at`, stamp(now)) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Trigger + for rows.Next() { + var t Trigger + var next, last sql.NullString + var created, updated string + if err := rows.Scan(&t.ID, &t.ObjectiveID, &t.Kind, &t.Spec, &t.MissedRunPolicy, &t.Status, &next, &last, &created, &updated); err != nil { + return nil, err + } + t.CreatedAt, _ = time.Parse(time.RFC3339Nano, created) + t.UpdatedAt, _ = time.Parse(time.RFC3339Nano, updated) + if next.Valid { + v, _ := time.Parse(time.RFC3339Nano, next.String) + t.NextDueAt = &v + } + if last.Valid { + v, _ := time.Parse(time.RFC3339Nano, last.String) + t.LastFiredAt = &v + } + out = append(out, t) + } + return out, rows.Err() +} + +func (s *Store) ClaimDueTrigger(ctx context.Context, id string, now time.Time) (Trigger, bool, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return Trigger{}, false, err + } + defer tx.Rollback() + var t Trigger + var next, last sql.NullString + var created, updated string + err = tx.QueryRowContext(ctx, `SELECT id,objective_id,kind,spec,missed_run_policy,status,next_due_at,last_fired_at,created_at,updated_at FROM triggers WHERE id=? AND status='enabled' AND next_due_at IS NOT NULL AND next_due_at<=?`, id, stamp(now)).Scan(&t.ID, &t.ObjectiveID, &t.Kind, &t.Spec, &t.MissedRunPolicy, &t.Status, &next, &last, &created, &updated) + if err == sql.ErrNoRows { + return Trigger{}, false, nil + } + if err != nil { + return Trigger{}, false, err + } + // Every self-scheduled wake is a single instant, so firing one completes it. + // (Recurring cadence never reaches this table — it is a gateway schedule.) + // The `last_fired_at IS ?` guard makes the claim atomic: a second gateway + // process racing on the same row updates zero rows and backs off. + fired := now.UTC() + t.Status = "completed" + res, err := tx.ExecContext(ctx, `UPDATE triggers SET status=?,last_fired_at=?,next_due_at=NULL,updated_at=? WHERE id=? AND last_fired_at IS ?`, t.Status, stamp(fired), stamp(fired), id, nullSQL(last)) + if err != nil { + return Trigger{}, false, err + } + n, _ := res.RowsAffected() + if n != 1 { + return Trigger{}, false, nil + } + if err := tx.Commit(); err != nil { + return Trigger{}, false, err + } + t.LastFiredAt = &fired + return t, true, nil +} + +func nullSQL(v sql.NullString) any { + if !v.Valid { + return nil + } + return v.String +} diff --git a/internal/agent/autonomy/schema.sql b/internal/agent/autonomy/schema.sql new file mode 100644 index 0000000..f5306cd --- /dev/null +++ b/internal/agent/autonomy/schema.sql @@ -0,0 +1,48 @@ +CREATE TABLE IF NOT EXISTS objectives ( + id TEXT PRIMARY KEY, description TEXT NOT NULL, success_criteria TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL, priority INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, updated_at TEXT NOT NULL, review_at TEXT +); +CREATE TABLE IF NOT EXISTS subgoals ( + id TEXT PRIMARY KEY, objective_id TEXT NOT NULL, parent_id TEXT, description TEXT NOT NULL, + status TEXT NOT NULL, priority INTEGER NOT NULL DEFAULT 0, rationale TEXT NOT NULL DEFAULT '', + dependencies_json TEXT NOT NULL DEFAULT '[]', created_at TEXT NOT NULL, updated_at TEXT NOT NULL, + FOREIGN KEY(objective_id) REFERENCES objectives(id) +); +CREATE TABLE IF NOT EXISTS runs ( + id TEXT PRIMARY KEY, objective_id TEXT NOT NULL, subgoal_id TEXT, parent_run_id TEXT, session_id TEXT, + envelope_json TEXT NOT NULL DEFAULT '{}', status TEXT NOT NULL, outcome_json TEXT, evidence_json TEXT, + created_at TEXT NOT NULL, updated_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS triggers ( + id TEXT PRIMARY KEY, objective_id TEXT NOT NULL, kind TEXT NOT NULL, spec TEXT NOT NULL, + missed_run_policy TEXT NOT NULL DEFAULT 'skip', status TEXT NOT NULL, + next_due_at TEXT, last_fired_at TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS policies ( + id TEXT PRIMARY KEY, objective_id TEXT NOT NULL, version INTEGER NOT NULL, document_json TEXT NOT NULL, + hash TEXT NOT NULL, status TEXT NOT NULL, approved_at TEXT, created_at TEXT NOT NULL, + UNIQUE(objective_id, version), UNIQUE(objective_id, hash) +); +CREATE TABLE IF NOT EXISTS resources ( + id TEXT PRIMARY KEY, objective_id TEXT NOT NULL, type TEXT NOT NULL, locator TEXT NOT NULL, + access_mode TEXT NOT NULL, constraints_json TEXT NOT NULL DEFAULT '{}', authorization_source TEXT NOT NULL, + policy_hash TEXT NOT NULL, expires_at TEXT, status TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS actions ( + id TEXT PRIMARY KEY, objective_id TEXT NOT NULL, subgoal_id TEXT, run_id TEXT, kind TEXT NOT NULL, + target TEXT NOT NULL DEFAULT '', consequence_class TEXT NOT NULL, policy_hash TEXT NOT NULL, + request_json TEXT NOT NULL DEFAULT '{}', idempotency_key TEXT, status TEXT NOT NULL, + result_json TEXT, evidence_json TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL +); +CREATE UNIQUE INDEX IF NOT EXISTS actions_idempotency ON actions(objective_id, idempotency_key) WHERE idempotency_key IS NOT NULL; +CREATE TABLE IF NOT EXISTS generated_items ( + id TEXT PRIMARY KEY, objective_id TEXT NOT NULL, path TEXT NOT NULL, hash TEXT NOT NULL, + purpose TEXT NOT NULL, source_run_id TEXT, parent_revision TEXT, + invocation_json TEXT NOT NULL DEFAULT '{}', evaluations_json TEXT NOT NULL DEFAULT '[]', + last_used_at TEXT, active_revision TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS notifications ( + id TEXT PRIMARY KEY, objective_id TEXT NOT NULL, kind TEXT NOT NULL, payload_json TEXT NOT NULL DEFAULT '{}', + status TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL +); diff --git a/internal/agent/autonomy/store.go b/internal/agent/autonomy/store.go new file mode 100644 index 0000000..e638726 --- /dev/null +++ b/internal/agent/autonomy/store.go @@ -0,0 +1,250 @@ +package autonomy + +import ( + "context" + "database/sql" + _ "embed" + "fmt" + "os" + "path/filepath" + "time" + + _ "modernc.org/sqlite" +) + +//go:embed schema.sql +var schema string + +//go:embed migrations/002_interactions.sql +var migration002 string + +//go:embed migrations/003_action_job_id.sql +var migration003 string + +// migrations is the ordered schema history. Version 1 is the base schema; later +// entries are additive ALTER/CREATE statements. Never edit a shipped entry. +var migrations = []string{schema, migration002, migration003} + +type Store struct{ db *sql.DB } + +// DB exposes the underlying handle for store-internal submodules (same +// package); external callers use Store methods only. +func (s *Store) DB() *sql.DB { return s.db } + +func InitializeHome(home string) error { + for _, entry := range []string{"policies", "workspace/generated", "workspace/scratch", "runs", "workers", ".memcode/jobs", ".memcode/sessions"} { + if err := os.MkdirAll(filepath.Join(home, entry), 0o700); err != nil { + return err + } + } + return nil +} + +func Open(ctx context.Context, home string) (*Store, error) { + if err := InitializeHome(home); err != nil { + return nil, fmt.Errorf("initialize agent home: %w", err) + } + // agent.db, not personal.db: this is state for any agent running + // unattended, not a separate species of agent. Opened lazily, so an + // ordinary conversational agent never grows one. + path := filepath.Join(home, "agent.db") + db, err := sql.Open("sqlite", path) + if err != nil { + return nil, fmt.Errorf("opening %s: %w", path, err) + } + for _, pragma := range []string{"PRAGMA busy_timeout=5000", "PRAGMA journal_mode=WAL", "PRAGMA foreign_keys=ON"} { + if _, err := db.ExecContext(ctx, pragma); err != nil { + db.Close() + return nil, fmt.Errorf("%s: %w", pragma, err) + } + } + if err := migrate(ctx, db); err != nil { + db.Close() + return nil, err + } + return &Store{db: db}, nil +} + +func migrate(ctx context.Context, db *sql.DB) error { + var version int + if err := db.QueryRowContext(ctx, "PRAGMA user_version").Scan(&version); err != nil { + return err + } + if version > len(migrations) { + return fmt.Errorf("personal agent schema version %d is newer than supported version %d", version, len(migrations)) + } + for i := version; i < len(migrations); i++ { + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return err + } + if _, err = tx.ExecContext(ctx, migrations[i]); err == nil { + _, err = tx.ExecContext(ctx, fmt.Sprintf("PRAGMA user_version = %d", i+1)) + } + if err != nil { + tx.Rollback() + return fmt.Errorf("applying autonomous agent migration %d: %w", i+1, err) + } + if err := tx.Commit(); err != nil { + return err + } + } + return nil +} + +func (s *Store) Close() error { return s.db.Close() } + +func (s *Store) CreateObjective(ctx context.Context, o Objective) error { + now := time.Now().UTC() + if o.CreatedAt.IsZero() { + o.CreatedAt = now + } + if o.UpdatedAt.IsZero() { + o.UpdatedAt = now + } + if o.Status == "" { + o.Status = "draft" + } + _, err := s.db.ExecContext(ctx, `INSERT INTO objectives(id,description,success_criteria,status,priority,created_at,updated_at,review_at) VALUES(?,?,?,?,?,?,?,?)`, o.ID, o.Description, o.SuccessCriteria, o.Status, o.Priority, stamp(o.CreatedAt), stamp(o.UpdatedAt), nullableTime(o.ReviewAt)) + return err +} + +func (s *Store) ListObjectives(ctx context.Context) ([]Objective, error) { + rows, err := s.db.QueryContext(ctx, `SELECT id,description,success_criteria,status,priority,created_at,updated_at,review_at FROM objectives ORDER BY created_at`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Objective + for rows.Next() { + var o Objective + var created, updated string + var review sql.NullString + if err := rows.Scan(&o.ID, &o.Description, &o.SuccessCriteria, &o.Status, &o.Priority, &created, &updated, &review); err != nil { + return nil, err + } + o.CreatedAt, _ = time.Parse(time.RFC3339Nano, created) + o.UpdatedAt, _ = time.Parse(time.RFC3339Nano, updated) + if review.Valid { + v, _ := time.Parse(time.RFC3339Nano, review.String) + o.ReviewAt = &v + } + out = append(out, o) + } + return out, rows.Err() +} + +func (s *Store) SetObjectiveStatus(ctx context.Context, id, status string) error { + res, err := s.db.ExecContext(ctx, `UPDATE objectives SET status=?,updated_at=? WHERE id=?`, status, stamp(time.Now().UTC()), id) + if err != nil { + return err + } + n, _ := res.RowsAffected() + if n == 0 { + return fmt.Errorf("objective %q not found", id) + } + return nil +} + +// SetObjectiveText updates an objective's description (the user-authored goal). +func (s *Store) SetObjectiveText(ctx context.Context, id, description string) error { + res, err := s.db.ExecContext(ctx, `UPDATE objectives SET description=?,updated_at=? WHERE id=?`, description, stamp(time.Now().UTC()), id) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return fmt.Errorf("objective %q not found", id) + } + return nil +} + +func (s *Store) StatusSummary(ctx context.Context) (map[string]int, error) { + out := map[string]int{} + for _, table := range []string{"objectives", "subgoals", "runs", "triggers", "policies", "resources", "actions", "generated_items", "notifications"} { + var n int + if err := s.db.QueryRowContext(ctx, "SELECT count(*) FROM "+table).Scan(&n); err != nil { + return nil, err + } + out[table] = n + } + return out, nil +} + +func (s *Store) RevokeResources(ctx context.Context, objectiveID string) error { + _, err := s.db.ExecContext(ctx, `UPDATE resources SET status='revoked',updated_at=? WHERE objective_id=? AND status='active'`, stamp(time.Now().UTC()), objectiveID) + return err +} +func (s *Store) CancelPendingNotifications(ctx context.Context) error { + _, err := s.db.ExecContext(ctx, `UPDATE notifications SET status='cancelled',updated_at=? WHERE status='pending'`, stamp(time.Now().UTC())) + return err +} +func (s *Store) ResolveUncertainAction(ctx context.Context, id string, status ActionStatus) error { + if status != ActionSucceeded && status != ActionFailed && status != ActionCancelled { + return fmt.Errorf("invalid reconciliation status") + } + res, err := s.db.ExecContext(ctx, `UPDATE actions SET status=?,updated_at=? WHERE id=? AND status='uncertain'`, status, stamp(time.Now().UTC()), id) + if err != nil { + return err + } + n, _ := res.RowsAffected() + if n != 1 { + return fmt.Errorf("uncertain action %q not found", id) + } + return nil +} +func (s *Store) RecoverableRuns(ctx context.Context) ([]Run, error) { + rows, err := s.db.QueryContext(ctx, `SELECT id,objective_id,subgoal_id,parent_run_id,session_id,status,created_at,updated_at FROM runs WHERE status IN ('running','waiting','resumable')`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Run + for rows.Next() { + var r Run + var sub, parent, session sql.NullString + var created, updated string + if err := rows.Scan(&r.ID, &r.ObjectiveID, &sub, &parent, &session, &r.Status, &created, &updated); err != nil { + return nil, err + } + r.SubgoalID = sub.String + r.ParentRunID = parent.String + r.SessionID = session.String + r.CreatedAt, _ = time.Parse(time.RFC3339Nano, created) + r.UpdatedAt, _ = time.Parse(time.RFC3339Nano, updated) + out = append(out, r) + } + return out, rows.Err() +} + +func (s *Store) DeleteObjective(ctx context.Context, id string) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM objectives WHERE id=?`, id) + return err +} + +func (s *Store) GetObjective(ctx context.Context, id string) (Objective, bool, error) { + var o Objective + var created, updated string + var review sql.NullString + err := s.db.QueryRowContext(ctx, `SELECT id,description,success_criteria,status,priority,created_at,updated_at,review_at FROM objectives WHERE id=?`, id).Scan(&o.ID, &o.Description, &o.SuccessCriteria, &o.Status, &o.Priority, &created, &updated, &review) + if err == sql.ErrNoRows { + return Objective{}, false, nil + } + if err != nil { + return Objective{}, false, err + } + o.CreatedAt, _ = time.Parse(time.RFC3339Nano, created) + o.UpdatedAt, _ = time.Parse(time.RFC3339Nano, updated) + if review.Valid { + t, _ := time.Parse(time.RFC3339Nano, review.String) + o.ReviewAt = &t + } + return o, true, nil +} + +func stamp(t time.Time) string { return t.UTC().Format(time.RFC3339Nano) } +func nullableTime(t *time.Time) any { + if t == nil { + return nil + } + return stamp(*t) +} diff --git a/internal/agent/autonomy/store_test.go b/internal/agent/autonomy/store_test.go new file mode 100644 index 0000000..eca1435 --- /dev/null +++ b/internal/agent/autonomy/store_test.go @@ -0,0 +1,202 @@ +package autonomy + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "sync" + "testing" + "time" +) + +func TestOpenInitializesHomeAndSchema(t *testing.T) { + ctx := context.Background() + home := filepath.Join(t.TempDir(), "agent") + s, err := Open(ctx, home) + if err != nil { + t.Fatal(err) + } + defer s.Close() + + for _, path := range []string{"agent.db", "policies", "workspace/generated", "workspace/scratch", "runs", "workers", ".memcode/jobs", ".memcode/sessions"} { + if _, err := os.Stat(filepath.Join(home, path)); err != nil { + t.Errorf("missing %s: %v", path, err) + } + } + for _, table := range []string{"objectives", "subgoals", "runs", "triggers", "policies", "resources", "actions", "generated_items", "notifications"} { + var name string + if err := s.db.QueryRowContext(ctx, `SELECT name FROM sqlite_master WHERE type='table' AND name=?`, table).Scan(&name); err != nil { + t.Errorf("table %s: %v", table, err) + } + } + var mode string + if err := s.db.QueryRowContext(ctx, "PRAGMA journal_mode").Scan(&mode); err != nil || mode != "wal" { + t.Errorf("journal mode=%q err=%v", mode, err) + } +} + +func TestObjectiveAndDomainNeutralRecordsPersist(t *testing.T) { + ctx := context.Background() + home := t.TempDir() + s, err := Open(ctx, home) + if err != nil { + t.Fatal(err) + } + if err := s.CreateObjective(ctx, Objective{ID: "o1", Description: "Maintain an arbitrary long-lived outcome", Status: "active", Priority: 3}); err != nil { + t.Fatal(err) + } + got, ok, err := s.GetObjective(ctx, "o1") + if err != nil || !ok || got.Description == "" || got.Priority != 3 { + t.Fatalf("objective=%+v ok=%v err=%v", got, ok, err) + } + if err := s.UpsertSubgoal(ctx, Subgoal{ID: "g1", ObjectiveID: "o1", Description: "observe state", Status: "pending"}); err != nil { + t.Fatal(err) + } + if err := s.CreateRun(ctx, Run{ID: "r1", ObjectiveID: "o1", Status: "running"}); err != nil { + t.Fatal(err) + } + if err := s.CreateTrigger(ctx, Trigger{ID: "t1", ObjectiveID: "o1", Kind: "manual", Spec: "{}"}); err != nil { + t.Fatal(err) + } + if err := s.InsertPolicy(ctx, Policy{ID: "p1", ObjectiveID: "o1", Version: 1, Document: json.RawMessage(`{}`), Hash: "h", Status: "draft"}); err != nil { + t.Fatal(err) + } + if err := s.InsertResource(ctx, Resource{ID: "res1", ObjectiveID: "o1", Type: "filesystem", Locator: "/tmp/x", AccessMode: "read", AuthorizationSource: "user", PolicyHash: "h"}); err != nil { + t.Fatal(err) + } + if err := s.InsertNotification(ctx, Notification{ID: "n1", ObjectiveID: "o1", Kind: "info"}); err != nil { + t.Fatal(err) + } + s.Close() + s, err = Open(ctx, home) + if err != nil { + t.Fatal(err) + } + defer s.Close() + for _, table := range []string{"subgoals", "runs", "triggers", "policies", "resources", "notifications"} { + var n int + if err := s.db.QueryRowContext(ctx, "SELECT count(*) FROM "+table).Scan(&n); err != nil || n != 1 { + t.Errorf("%s count=%d err=%v", table, n, err) + } + } +} + +func TestPersistentTriggerClaim(t *testing.T) { + ctx := context.Background() + s, err := Open(ctx, t.TempDir()) + if err != nil { + t.Fatal(err) + } + defer s.Close() + now := time.Now().UTC().Truncate(time.Second) + due := now.Add(-time.Minute) + // A self-scheduled wake: one instant, set by the agent from inside a run. + if err := s.CreateTrigger(ctx, Trigger{ID: "t1", ObjectiveID: "o1", Kind: "next_wake", Spec: due.Format(time.RFC3339), NextDueAt: &due}); err != nil { + t.Fatal(err) + } + got, ok, err := s.ClaimDueTrigger(ctx, "t1", now) + if err != nil || !ok || got.LastFiredAt == nil { + t.Fatalf("trigger=%+v ok=%v err=%v", got, ok, err) + } + // The claim is atomic — a second gateway process racing on the same row + // must lose rather than double-firing the wake. + if _, ok, err := s.ClaimDueTrigger(ctx, "t1", now); err != nil || ok { + t.Fatalf("duplicate claim ok=%v err=%v", ok, err) + } + triggers, err := s.ListTriggers(ctx) + if err != nil || len(triggers) != 1 { + t.Fatalf("triggers=%+v err=%v", triggers, err) + } + // Firing completes it: a one-instant wake never reschedules itself. + if triggers[0].Status != "completed" || triggers[0].NextDueAt != nil { + t.Fatalf("expected a completed wake with no next due, got %+v", triggers[0]) + } +} + +func TestNextDueKinds(t *testing.T) { + now := time.Date(2026, time.August, 30, 12, 0, 0, 0, time.UTC) + for _, tc := range []struct{ kind, spec string }{{"manual", ""}, {"one_shot", "2026-08-30T13:00:00Z"}, {"next_wake", "2026-08-30T13:00:00Z"}} { + if _, err := NextDue(tc.kind, tc.spec, now); err != nil { + t.Errorf("%s: %v", tc.kind, err) + } + } + // Recurring kinds are deliberately NOT understood here: a second cron + // parser in this package is what let the two schedulers drift. Human + // cadence is a gateway schedule (gw_schedule), not a trigger row. + for _, kind := range []string{"interval", "cron"} { + if _, err := NextDue(kind, "5m", now); err == nil { + t.Errorf("%s accepted — recurring cadence must go through gwconfig, not a second parser here", kind) + } + } +} + +func TestStatusRecoveryRevocationAndUncertainResolution(t *testing.T) { + ctx := context.Background() + s, err := Open(ctx, t.TempDir()) + if err != nil { + t.Fatal(err) + } + defer s.Close() + if err := s.CreateObjective(ctx, Objective{ID: "o1", Description: "arbitrary", Status: "active"}); err != nil { + t.Fatal(err) + } + if err := s.CreateRun(ctx, Run{ID: "r1", ObjectiveID: "o1", Status: "waiting"}); err != nil { + t.Fatal(err) + } + if err := s.InsertResource(ctx, Resource{ID: "res1", ObjectiveID: "o1", Type: "filesystem", Locator: "/tmp", AccessMode: "read", AuthorizationSource: "user", PolicyHash: "h", Status: "active"}); err != nil { + t.Fatal(err) + } + a, _, err := s.ReserveAction(ctx, ActionIntent{ID: "a1", ObjectiveID: "o1", Kind: "write", Consequence: LocalMutation, PolicyHash: "h"}) + if err != nil { + t.Fatal(err) + } + if err := s.CompleteAction(ctx, a.ID, ActionUncertain, nil, nil); err != nil { + t.Fatal(err) + } + if err := s.ResolveUncertainAction(ctx, "a1", ActionFailed); err != nil { + t.Fatal(err) + } + if err := s.RevokeResources(ctx, "o1"); err != nil { + t.Fatal(err) + } + runs, err := s.RecoverableRuns(ctx) + if err != nil || len(runs) != 1 { + t.Fatalf("runs=%+v err=%v", runs, err) + } + summary, err := s.StatusSummary(ctx) + if err != nil || summary["objectives"] != 1 { + t.Fatalf("summary=%v err=%v", summary, err) + } +} + +func TestConcurrentWALAccess(t *testing.T) { + ctx := context.Background() + home := t.TempDir() + a, err := Open(ctx, home) + if err != nil { + t.Fatal(err) + } + defer a.Close() + b, err := Open(ctx, home) + if err != nil { + t.Fatal(err) + } + defer b.Close() + var wg sync.WaitGroup + errs := make(chan error, 2) + for i, s := range []*Store{a, b} { + wg.Add(1) + go func(i int, s *Store) { + defer wg.Done() + errs <- s.CreateObjective(ctx, Objective{ID: string(rune('a' + i)), Description: "concurrent", Status: "active"}) + }(i, s) + } + wg.Wait() + close(errs) + for err := range errs { + if err != nil { + t.Fatal(err) + } + } +} diff --git a/internal/agent/continuation/continuation.go b/internal/agent/continuation/continuation.go new file mode 100644 index 0000000..264f4f8 --- /dev/null +++ b/internal/agent/continuation/continuation.go @@ -0,0 +1,176 @@ +// Package continuation is the ONE durable suspend/resume mechanism for an agent +// turn that stops mid-flight to wait for a human. +// +// It exists as its own package because two very different callers need it and +// neither should depend on the other: an interactive session (which already +// holds the conversation in memory and only needs the missing pair of messages +// back) and an unattended executive (which keeps no transcript at all — it +// rebuilds context from durable state each wake, so the continuation must carry +// the conversation itself). Before this package there were three partial +// designs: a typed-but-unused one in internal/agent/runtime, a set of +// declared-but-never-written fields on jobs.Job, and a hand-rolled map[string]any +// in the personal executive that was the only one actually running. Keep it one. +// +// The invariant that makes resume exact: a suspending tool must be the SOLE +// tool use in its assistant response (ValidateSingletonSuspension). Otherwise a +// sibling tool call in the same batch would be silently dropped on resume, or +// re-executed — both wrong. Save refuses to write a suspension that violates it, +// so the error surfaces at suspend time rather than as corruption at resume. +package continuation + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/memcode-ai/memcode/internal/atomicfile" + "github.com/memcode-ai/memcode/internal/wire" +) + +// Suspension is the exact continuation of a turn paused on a human answer. +type Suspension struct { + Version int `json:"version"` + SessionID string `json:"session_id,omitempty"` + RunID string `json:"run_id,omitempty"` + InteractionID string `json:"interaction_id"` + ToolUseID string `json:"tool_use_id"` + ToolName string `json:"tool_name,omitempty"` + ToolInput json.RawMessage `json:"tool_input,omitempty"` + // Assistant is the response carrying the suspending tool use. + Assistant wire.Message `json:"assistant"` + // Messages is the full transcript up to and including Assistant. Set it + // when the caller keeps no transcript of its own; Resolve then hands back + // the whole conversation plus the answer. Leave it empty when the caller + // still holds the prior turns — Resolve then returns only the two messages + // to append, so the transcript is never duplicated. + Messages []wire.Message `json:"messages,omitempty"` + CreatedAt time.Time `json:"created_at"` + Resolved bool `json:"resolved"` +} + +// SessionDir is where an interactive session's continuations live. +func SessionDir(root, sessionID string) string { + return filepath.Join(root, ".memcode", "sessions", sessionID, "continuations") +} + +func path(dir, interactionID string) string { + return filepath.Join(dir, interactionID+".json") +} + +// ValidateSingletonSuspension enforces that the suspending tool is the only +// tool use in its assistant response — see the package comment. +func ValidateSingletonSuspension(msg wire.Message, toolUseID string) error { + var tools int + for _, b := range msg.Blocks { + if b.Type == "tool_use" { + tools++ + if b.ID != toolUseID && toolUseID != "" { + return fmt.Errorf("suspending tool %q does not match assistant tool use %q", toolUseID, b.ID) + } + } + } + if tools != 1 { + return fmt.Errorf("a suspending action must be the only tool use in its assistant response; got %d tool uses", tools) + } + return nil +} + +// Save writes the continuation atomically. A crash mid-write must not be able +// to leave a truncated file — that would strand the interaction with no way to +// resume it. +func Save(dir string, s Suspension) error { + if s.Version == 0 { + s.Version = 1 + } + if s.CreatedAt.IsZero() { + s.CreatedAt = time.Now().UTC() + } + if err := ValidateSingletonSuspension(s.Assistant, s.ToolUseID); err != nil { + return err + } + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + b, err := json.Marshal(s) + if err != nil { + return err + } + return atomicfile.WriteFile(path(dir, s.InteractionID), b, 0o600) +} + +// Load reads an unresolved continuation. An already-resolved one is an error, +// not an empty result: resuming it twice would re-execute real side effects. +func Load(dir, interactionID string) (Suspension, error) { + b, err := os.ReadFile(path(dir, interactionID)) + if err != nil { + return Suspension{}, err + } + var s Suspension + if err := json.Unmarshal(b, &s); err != nil { + return Suspension{}, err + } + if s.Resolved { + return Suspension{}, fmt.Errorf("interaction %q is already resolved", interactionID) + } + return s, nil +} + +// ResumeMessages builds the messages to resume with — the full transcript plus +// the answer when Messages was recorded, or just the assistant/answer pair when +// the caller holds the transcript itself — WITHOUT marking the continuation +// resolved. +// +// Separated from Resolve because the two callers differ on when it is safe to +// mark: an interactive session has the human right there and can simply ask +// again, so it marks immediately; an unattended executive must keep the +// interaction retryable until the resumed run actually reaches a terminal +// state, or a transient model error would strand the answer with no way to +// re-give it. +// +// The result block must match the suspended tool_use_id — a mismatched pairing +// is how a resume silently answers the wrong question. +func (s Suspension) ResumeMessages(result wire.Block) ([]wire.Message, error) { + if s.Resolved { + return nil, fmt.Errorf("interaction %q is already resolved", s.InteractionID) + } + if result.Type != "tool_result" || result.ToolUseID != s.ToolUseID { + return nil, fmt.Errorf("tool result id %q does not match suspended tool %q", result.ToolUseID, s.ToolUseID) + } + answer := wire.Message{Role: "user", Blocks: []wire.Block{result}} + if len(s.Messages) > 0 { + return append(append([]wire.Message{}, s.Messages...), answer), nil + } + return []wire.Message{s.Assistant, answer}, nil +} + +// Resolve builds the resume messages and marks the continuation resolved in one +// step, for a caller that can afford to lose the retry (see ResumeMessages). +func Resolve(dir string, s Suspension, result wire.Block) ([]wire.Message, error) { + out, err := s.ResumeMessages(result) + if err != nil { + return nil, err + } + if err := MarkResolved(dir, s.InteractionID); err != nil { + return nil, err + } + return out, nil +} + +// MarkResolved records that a continuation has been consumed without producing +// resume messages — for a caller that resolved the interaction by another route +// (cancelled, or a resumed run that itself re-suspended on a new question) and +// must not leave the old file loadable. +func MarkResolved(dir, interactionID string) error { + s, err := Load(dir, interactionID) + if err != nil { + return err + } + s.Resolved = true + b, err := json.Marshal(s) + if err != nil { + return err + } + return atomicfile.WriteFile(path(dir, interactionID), b, 0o600) +} diff --git a/internal/agent/continuation/continuation_test.go b/internal/agent/continuation/continuation_test.go new file mode 100644 index 0000000..26c610a --- /dev/null +++ b/internal/agent/continuation/continuation_test.go @@ -0,0 +1,111 @@ +package continuation + +import ( + "encoding/json" + "path/filepath" + "testing" + + "github.com/memcode-ai/memcode/internal/wire" +) + +func TestRoundTripPreservesReasoningAndTool(t *testing.T) { + dir := t.TempDir() + assistant := wire.Message{Role: "assistant", Blocks: []wire.Block{ + {Type: "thinking", Thinking: "reason", Signature: "sig"}, + {Type: "tool_use", ID: "tool-1", Name: "ask_user", Input: json.RawMessage(`{"question":"continue?"}`)}, + }} + s := Suspension{SessionID: "session-1", InteractionID: "interaction-1", ToolUseID: "tool-1", ToolName: "ask_user", ToolInput: assistant.Blocks[1].Input, Assistant: assistant} + if err := Save(dir, s); err != nil { + t.Fatal(err) + } + got, err := Load(dir, "interaction-1") + if err != nil { + t.Fatal(err) + } + // Thinking signature must survive the round trip — dropping it invalidates + // the assistant turn when it is replayed to the model. + if got.Assistant.Blocks[0].Signature != "sig" || got.ToolUseID != "tool-1" { + t.Fatalf("suspension=%+v", got) + } + msgs, err := Resolve(dir, got, wire.Block{Type: "tool_result", ToolUseID: "tool-1", Content: "yes"}) + if err != nil { + t.Fatal(err) + } + if len(msgs) != 2 || msgs[1].Blocks[0].ToolUseID != "tool-1" { + t.Fatalf("messages=%+v", msgs) + } + if _, err := Load(dir, "interaction-1"); err == nil { + t.Fatal("resolved suspension loaded again — a second resume would re-run real side effects") + } +} + +func TestRejectsMixedBatchAndMismatchedResult(t *testing.T) { + dir := t.TempDir() + mixed := wire.Message{Role: "assistant", Blocks: []wire.Block{{Type: "tool_use", ID: "a"}, {Type: "tool_use", ID: "b"}}} + if err := Save(dir, Suspension{InteractionID: "i", ToolUseID: "a", Assistant: mixed}); err == nil { + t.Fatal("mixed tool batch accepted — a sibling call would be dropped or re-run on resume") + } + single := wire.Message{Role: "assistant", Blocks: []wire.Block{{Type: "tool_use", ID: "a", Name: "approval"}}} + if err := Save(dir, Suspension{InteractionID: "i2", ToolUseID: "a", Assistant: single}); err != nil { + t.Fatal(err) + } + loaded, err := Load(dir, "i2") + if err != nil { + t.Fatal(err) + } + if _, err := Resolve(dir, loaded, wire.Block{Type: "tool_result", ToolUseID: "wrong"}); err == nil { + t.Fatal("mismatched result accepted — resume would answer the wrong question") + } +} + +// The unattended-executive case: no transcript of its own, so the continuation +// carries the whole conversation and Resolve hands all of it back. +func TestFullTranscriptCarriedForTranscriptlessCaller(t *testing.T) { + dir := t.TempDir() + assistant := wire.Message{Role: "assistant", Blocks: []wire.Block{{Type: "tool_use", ID: "t1", Name: "ask_user"}}} + msgs := []wire.Message{ + {Role: "user", Blocks: []wire.Block{wire.TextBlock("advance the objective")}}, + {Role: "assistant", Blocks: []wire.Block{wire.TextBlock("checking")}}, + assistant, + } + if err := Save(dir, Suspension{InteractionID: "i", ToolUseID: "t1", Assistant: assistant, Messages: msgs}); err != nil { + t.Fatal(err) + } + loaded, err := Load(dir, "i") + if err != nil { + t.Fatal(err) + } + out, err := Resolve(dir, loaded, wire.Block{Type: "tool_result", ToolUseID: "t1", Content: "yes"}) + if err != nil { + t.Fatal(err) + } + // Whole transcript + the answer, with nothing duplicated. + if len(out) != len(msgs)+1 { + t.Fatalf("expected %d messages, got %d: %+v", len(msgs)+1, len(out), out) + } + if out[len(out)-1].Blocks[0].ToolUseID != "t1" { + t.Fatalf("answer not appended: %+v", out) + } +} + +func TestMarkResolvedBlocksReload(t *testing.T) { + dir := t.TempDir() + assistant := wire.Message{Role: "assistant", Blocks: []wire.Block{{Type: "tool_use", ID: "t1", Name: "ask_user"}}} + if err := Save(dir, Suspension{InteractionID: "i", ToolUseID: "t1", Assistant: assistant}); err != nil { + t.Fatal(err) + } + if err := MarkResolved(dir, "i"); err != nil { + t.Fatal(err) + } + if _, err := Load(dir, "i"); err == nil { + t.Fatal("expected a marked-resolved continuation to refuse loading") + } +} + +func TestSessionDirLayout(t *testing.T) { + got := SessionDir("/repo", "sess_abc") + want := filepath.Join("/repo", ".memcode", "sessions", "sess_abc", "continuations") + if got != want { + t.Fatalf("got %q want %q", got, want) + } +} diff --git a/internal/agent/runtime/admin.go b/internal/agent/runtime/admin.go index 440879b..8b4a878 100644 --- a/internal/agent/runtime/admin.go +++ b/internal/agent/runtime/admin.go @@ -23,15 +23,22 @@ type AdminExecutor func(ctx context.Context, name string, input json.RawMessage) // adminReadOnly reports whether an admin call needs no approval: pure reads. func adminReadOnly(name string, input json.RawMessage) bool { - if name == tools.GwOverview { + switch name { + case tools.GwOverview, tools.GwInbox, tools.GwJournal, tools.GwDoctor, tools.GwBrowser: return true } - if name == tools.GwService { - var in struct { - Action string `json:"action"` - } - _ = json.Unmarshal(input, &in) - return strings.EqualFold(strings.TrimSpace(in.Action), "status") + var in struct { + Action string `json:"action"` + } + _ = json.Unmarshal(input, &in) + a := strings.ToLower(strings.TrimSpace(in.Action)) + switch name { + case tools.GwService: + return a == "status" + case tools.GwPolicy: + return a == "show" + case tools.GwGrant: + return a == "list" } return false } diff --git a/internal/agent/runtime/exec.go b/internal/agent/runtime/exec.go index d004efd..c6dc732 100644 --- a/internal/agent/runtime/exec.go +++ b/internal/agent/runtime/exec.go @@ -313,7 +313,8 @@ func (s *Session) dispatch(ctx context.Context, u wire.Block) toolResult { return s.mcpResourceTool(ctx, u.Input) case tools.MCPPrompt: return s.mcpPromptTool(ctx, u.Input) - case tools.GwOverview, tools.GwChannel, tools.GwPairing, tools.GwProject, tools.GwAgent, tools.GwSchedule, tools.GwService: + case tools.GwOverview, tools.GwChannel, tools.GwPairing, tools.GwProject, tools.GwAgent, tools.GwSchedule, tools.GwService, + tools.GwPolicy, tools.GwGrant, tools.GwWake, tools.GwInbox, tools.GwAnswer, tools.GwJournal, tools.GwDoctor, tools.GwBrowser: return s.adminTool(ctx, u.Name, u.Input) case tools.GitHub: return s.githubTool(ctx, u.Input) diff --git a/internal/agent/runtime/mcp.go b/internal/agent/runtime/mcp.go index 6c87fc1..9934eea 100644 --- a/internal/agent/runtime/mcp.go +++ b/internal/agent/runtime/mcp.go @@ -47,6 +47,13 @@ func (s *Session) connectMCP(ctx context.Context, interactive bool) { connect[ss.Name] = mcp.ExpandServer(ss.Config) s.mcpConfigs[ss.Name] = ss.Config } + // Programmatically-set servers (currently: existing-Chrome, see + // SetExtraMCPServers) are already trusted by the caller that set them — + // no approval gate, same as a locally-configured server. + for name, cfg := range s.extraMCPServers { + connect[name] = mcp.ExpandServer(cfg) + s.mcpConfigs[name] = cfg + } s.mcpInteractive = interactive s.mcp = mcp.Connect(ctx, connect, mcp.Options{Version: mcpClientVersion, AllowOAuth: interactive}) s.reportMCP() diff --git a/internal/agent/runtime/runtime.go b/internal/agent/runtime/runtime.go index 1c6e50b..ee48779 100644 --- a/internal/agent/runtime/runtime.go +++ b/internal/agent/runtime/runtime.go @@ -128,6 +128,7 @@ type Session struct { lspOnce sync.Once // guards lazy lspMgr creation mcpPending []mcp.ScopedServer // project-scoped servers awaiting approval (reviewed on the first interactive turn) mcpConfigs map[string]mcp.ServerConfig // connected servers' configs (invocation grants key to their hash) + extraMCPServers map[string]mcp.ServerConfig // set programmatically (e.g. existing-Chrome), merged in at connect time — see SetExtraMCPServers mcpInteractive bool // this session can complete interactive flows (approval prompts, OAuth browser) mcpErrsShown int // count of MCP connect errors already surfaced (so Add doesn't re-print) bgCtx context.Context // LONG-LIVED ctx for jobs (session-scoped, NOT a turn ctx) @@ -366,6 +367,10 @@ func (s *Session) SetAdmin(exec AdminExecutor) { s.adminExec = exec } +// Restricted reports whether the session is a restricted management console +// (admin): a limited slash whitelist, no repo/coding tools. +func (s *Session) Restricted() bool { return s.adminMode } + // Admin reports whether this is an admin session (the TUI swaps its slash set). func (s *Session) Admin() bool { return s.adminMode } @@ -373,6 +378,16 @@ func (s *Session) SetBrowserEnabled(enabled bool) { s.browserEnabled = enabled } +// SetExtraMCPServers adds server configs that were NOT discovered from +// .mcp.json (project/user/local config) — currently used for one thing: +// handing this run its own chrome-devtools-mcp connection to the user's +// existing Chrome, after the caller has already acquired a broker lease. The +// caller is responsible for that lease; this only wires the resulting MCP +// server into the session like any other. +func (s *Session) SetExtraMCPServers(servers map[string]mcp.ServerConfig) { + s.extraMCPServers = servers +} + // BrowserEnabled reports whether --chrome is active (browser tools are advertised // and a Chrome session may be launched). Used by the TUI's /dispatch to forward // the capability to spawned sub-agents. diff --git a/internal/agent/tools/admin.go b/internal/agent/tools/admin.go index 77ff69a..525bf22 100644 --- a/internal/agent/tools/admin.go +++ b/internal/agent/tools/admin.go @@ -11,9 +11,22 @@ const ( GwChannel = "gw_channel" // per-channel settings: allow list, agent, tier, pairing, voice, group behavior GwPairing = "gw_pairing" // approve/deny a pending pairing code GwProject = "gw_project" // register/remove working directories - GwAgent = "gw_agent" // create/remove agents + GwAgent = "gw_agent" // create/remove agents; objective, autonomy, browser, pause GwSchedule = "gw_schedule" // recurring tasks (cron) GwService = "gw_service" // the background daemon: status, install, uninstall + + // Autonomy tools — these apply to an agent allowed to run unattended + // (gw_agent action=autonomous). They are ordinary admin tools, not a + // separate species of agent: an autonomous agent is an agent with an + // objective, an approved policy, and permission to act on its own. + GwPolicy = "gw_policy" // stage/show/approve the delegation policy (the authority ceremony) + GwGrant = "gw_grant" // grant/list/revoke resources (filesystem paths, mcp tools, ...) + GwWake = "gw_wake" // run one bounded wake now + GwInbox = "gw_inbox" // questions an agent is suspended on + GwAnswer = "gw_answer" // answer one, resuming the suspended run + GwJournal = "gw_journal" // recent runs + the consequential-action journal + GwDoctor = "gw_doctor" // health check an agent's home, objective, policy, wakes + GwBrowser = "gw_browser" // check/connect the user's existing Chrome for browser work ) // AdminDefs returns the admin session's tool registry. @@ -52,19 +65,22 @@ func AdminDefs() []wire.ToolDef { }, { Name: GwAgent, - Description: "Create or remove an agent: a lasting assistant identity with its own memory and skills (identity file: ~/.memcode/agents//SOUL.md). Bind a channel to one with gw_channel field=agent. action=model pins/clears its model; action=reasoning pins/clears its thinking effort; action=tools sets its tool policy (toolsets allow-list and/or disabled list; valid names: files, shell, code, web, browser, mcp, memory, skills, delegation, planning, interaction, or an individual tool name).", + Description: "Create, configure, or remove a lasting agent identity with its own memory and skills; use gw_overview to inspect existing agents (identity file: ~/.memcode/agents//SOUL.md). Bind agents to channels with gw_channel field=agent. action=model pins/clears its model; action=reasoning pins/clears its thinking effort; action=tools sets its tool policy; action=objective sets the durable outcome it works toward; action=autonomous grants or revokes permission to run unattended; action=browser picks its browser backend; action=pause/resume stops or restarts unattended wakes without deleting anything.\n\nobjective and autonomous are SEPARATE grants and must be proposed separately: an objective says what the agent is for, autonomous says it may act on that without being asked. An agent can hold an objective you only ever work on together, and an agent can run unattended on a schedule with no standing objective at all.", InputSchema: obj(map[string]any{ - "action": str("add, remove, model, reasoning, or tools"), - "name": str("agent name, e.g. personal, coder, researcher"), + "action": str("add, objective, autonomous, browser, pause, resume, tools, reasoning, model, or remove"), + "name": str("agent name, e.g. assistant, coder, researcher"), "model": str("add/model: pin the model that drives this agent everywhere (catalog id, e.g. \"claude-sonnet-5\"); empty = automatic routing"), "reasoning": str("add/reasoning: pin thinking effort — off, medium, or high; empty = per-turn automatic"), "toolsets": str("tools: comma-separated allow-list of toolsets/tools; empty = all"), "disabled_toolsets": str("tools: comma-separated toolsets/tools to remove; deny wins over allow"), + "objective": str("add/objective: the durable outcome this agent works toward, e.g. \"Find backend roles and keep a shortlist\"; empty clears it"), + "autonomous": str("autonomous: \"true\" to let it run unattended (policy-gated, action-journaled, suspends durably on questions), anything else to revoke"), + "browser": str("add/browser: \"existing_chrome\" to drive the user's OWN running, signed-in Chrome; \"ephemeral\" (default) for a fresh logged-out profile"), }, "action", "name"), }, { Name: GwSchedule, - Description: "Manage scheduled tasks. add creates one (recurring via cron/every, or a one-shot via at); remove deletes; disable pauses without deleting; enable resumes. deliver_to routes the result to a conversation, e.g. \"telegram:123456789\".", + Description: "Manage scheduled tasks. add creates one (recurring via cron/every, or a one-shot via at); remove deletes; disable pauses without deleting; enable resumes. deliver_to routes the result to a conversation, e.g. \"telegram:123456789\".\n\nThis is ALSO how an autonomous agent gets its recurring cadence: set agent= and leave deliver_to empty, and the wake is delivered to the agent itself (its report is journaled in its home rather than sent to a chat). There is no separate scheduler for autonomous agents.", InputSchema: obj(map[string]any{ "action": str("add, remove, enable, or disable"), "name": str("schedule name"), @@ -72,7 +88,7 @@ func AdminDefs() []wire.ToolDef { "every": str("add only: interval as a Go duration, e.g. \"30m\""), "at": str("add only: one-shot RFC3339 time, e.g. \"2026-03-01T09:00:00Z\""), "task": str("add only: the task to run, in plain language"), - "deliver_to": str("add only: where the result goes, channel:conversation"), + "deliver_to": str("add only: where the result goes, channel:conversation. Omit it when agent= names an autonomous agent — the wake then goes to the agent itself."), "agent": str("add only: run as this agent (its pinned model and instructions apply)"), }, "action", "name"), }, @@ -83,5 +99,69 @@ func AdminDefs() []wire.ToolDef { "action": str("status, install, or uninstall"), }, "action"), }, + { + Name: GwPolicy, + Description: "The delegation policy that bounds what an agent may do while running unattended. action=show (the approved one), action=stage (write a draft from a DelegationPolicy JSON in 'document'), action=approve (by hash). Approval is deliberately a two-step ceremony pinned by hash: an unattended agent cannot ask permission mid-run, so the authority it will use has to be reviewed and fixed in advance. Consequential work stays blocked until a policy is approved.", + InputSchema: obj(map[string]any{ + "agent": str("agent name"), + "action": str("show, stage, or approve"), + "document": str("stage only: the DelegationPolicy JSON (allowed_tools, consequence_classes, max_seconds, max_actions_per_period, max_delegation_depth, ...)"), + "hash": str("approve only: the policy hash or a unique prefix, as returned by stage"), + }, "agent", "action"), + }, + { + Name: GwGrant, + Description: "Grant or revoke a resource an agent may reach. action=grant (locator, optionally type and mode), action=list, action=revoke (id). type defaults to filesystem and mode to read — the common case is just a path. Filesystem paths are canonicalized and symlink-resolved, and a grant may be a single file or a whole directory. Revoking takes effect at the agent's next dispatch.", + InputSchema: obj(map[string]any{ + "agent": str("agent name"), + "action": str("grant, list, or revoke"), + "type": str("grant only: filesystem (default), mcp, command, channel, repository"), + "locator": str("grant only: the path or identifier, e.g. ~/resume.md"), + "mode": str("grant only: read (default), write, or admin"), + "id": str("revoke only: the resource id from list"), + }, "agent", "action"), + }, + { + Name: GwWake, + Description: "Run one bounded wake for an agent right now, without waiting for its schedule. Fails closed if no policy is approved. Returns the run's status and report. Works on any agent with an objective — an agent does not have to be autonomous to be woken on demand; autonomy only governs whether it wakes on its own.", + InputSchema: obj(map[string]any{ + "agent": str("agent name"), + }, "agent"), + }, + { + Name: GwInbox, + Description: "List the questions an agent is suspended waiting on. An unattended run that needs a human does not prompt — it suspends durably and waits here.", + InputSchema: obj(map[string]any{ + "agent": str("agent name"), + }, "agent"), + }, + { + Name: GwAnswer, + Description: "Answer a pending question, resuming the suspended run from the exact point it paused — nothing already done is repeated.", + InputSchema: obj(map[string]any{ + "agent": str("agent name"), + "id": str("the interaction id from gw_inbox"), + "answer": str("the human's answer"), + }, "agent", "id", "answer"), + }, + { + Name: GwJournal, + Description: "Show an agent's recent runs and its journal of consequential actions — what it actually did, under which approved policy. This is the audit trail for work done while nobody was watching.", + InputSchema: obj(map[string]any{ + "agent": str("agent name"), + }, "agent"), + }, + { + Name: GwDoctor, + Description: "Health check an agent set up to run on its own: home directory layout, objective, approved policy, generated workspace, sandbox availability, scheduled wakes, and pending questions. Use it when something looks wrong, or before the first wake.", + InputSchema: obj(map[string]any{ + "agent": str("agent name"), + }, "agent"), + }, + { + Name: GwBrowser, + Description: "Check whether browser work against the user's OWN running Chrome is ready: verifies npx and the gateway's browser broker, then attempts a real, bounded connection. Call it when an agent's browser work fails closed, or when setting up an agent with browser=existing_chrome. It cannot click Chrome's own Allow dialog — only the user can do that; report what it needs instead.", + InputSchema: obj(map[string]any{}), + }, } } diff --git a/internal/browser/broker/broker.go b/internal/browser/broker/broker.go new file mode 100644 index 0000000..6788b3d --- /dev/null +++ b/internal/browser/broker/broker.go @@ -0,0 +1,75 @@ +package broker + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "sync" + "time" +) + +type Lease struct { + ID, AgentID, RunID, Token string + ExpiresAt time.Time + OwnedPages map[string]bool +} +type Broker struct { + mu sync.Mutex + lease *Lease +} + +func New() *Broker { return &Broker{} } +func (b *Broker) Acquire(agentID, runID string, ttl time.Duration) (Lease, error) { + b.mu.Lock() + defer b.mu.Unlock() + if b.lease != nil && time.Now().Before(b.lease.ExpiresAt) { + return Lease{}, fmt.Errorf("browser control is leased to another run") + } + raw := make([]byte, 24) + if _, err := rand.Read(raw); err != nil { + return Lease{}, err + } + l := Lease{ID: hex.EncodeToString(raw[:8]), AgentID: agentID, RunID: runID, Token: hex.EncodeToString(raw), ExpiresAt: time.Now().Add(ttl), OwnedPages: map[string]bool{}} + b.lease = &l + return l, nil +} +func (b *Broker) Authenticate(token string) bool { + b.mu.Lock() + defer b.mu.Unlock() + return b.lease != nil && time.Now().Before(b.lease.ExpiresAt) && token == b.lease.Token +} +func (b *Broker) OwnPage(token, page string) error { + b.mu.Lock() + defer b.mu.Unlock() + if b.lease == nil || token != b.lease.Token || !time.Now().Before(b.lease.ExpiresAt) { + return fmt.Errorf("invalid browser lease") + } + b.lease.OwnedPages[page] = true + return nil +} +func (b *Broker) CanMutate(token, page string) bool { + b.mu.Lock() + defer b.mu.Unlock() + return b.lease != nil && token == b.lease.Token && time.Now().Before(b.lease.ExpiresAt) && b.lease.OwnedPages[page] +} +func (b *Broker) Release(token string) bool { + b.mu.Lock() + defer b.mu.Unlock() + if b.lease == nil || token != b.lease.Token { + return false + } + b.lease = nil + return true +} +func RedactHeaders(headers map[string]string) map[string]string { + out := map[string]string{} + for k, v := range headers { + switch k { + case "Authorization", "authorization", "Cookie", "cookie", "Set-Cookie", "set-cookie": + out[k] = "[redacted]" + default: + out[k] = v + } + } + return out +} diff --git a/internal/browser/broker/broker_test.go b/internal/browser/broker/broker_test.go new file mode 100644 index 0000000..8150f71 --- /dev/null +++ b/internal/browser/broker/broker_test.go @@ -0,0 +1,38 @@ +package broker + +import ( + "testing" + "time" +) + +func TestLeaseAuthenticationOwnershipAndRelease(t *testing.T) { + b := New() + l, err := b.Acquire("agent", "run", time.Minute) + if err != nil { + t.Fatal(err) + } + if !b.Authenticate(l.Token) { + t.Fatal("valid token denied") + } + if b.CanMutate(l.Token, "existing-user-tab") { + t.Fatal("unowned tab allowed") + } + if err := b.OwnPage(l.Token, "owned-tab"); err != nil { + t.Fatal(err) + } + if !b.CanMutate(l.Token, "owned-tab") { + t.Fatal("owned tab denied") + } + if _, err := b.Acquire("other", "run", time.Minute); err == nil { + t.Fatal("concurrent lease accepted") + } + if !b.Release(l.Token) || b.Authenticate(l.Token) { + t.Fatal("lease not released") + } +} +func TestHeaderRedaction(t *testing.T) { + got := RedactHeaders(map[string]string{"Authorization": "secret", "Cookie": "session", "Accept": "json"}) + if got["Authorization"] != "[redacted]" || got["Cookie"] != "[redacted]" || got["Accept"] != "json" { + t.Fatalf("headers=%v", got) + } +} diff --git a/internal/browser/broker/client.go b/internal/browser/broker/client.go new file mode 100644 index 0000000..1c235c5 --- /dev/null +++ b/internal/browser/broker/client.go @@ -0,0 +1,107 @@ +package broker + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net" + "net/http" + "os" + "time" +) + +// Client talks to a gateway-owned Server over its Unix socket. A delegated +// worker is a separate OS process from the gateway (see jobs.SpawnWithSpec), +// so it cannot hold the *Broker* itself — this is how it reaches the SAME +// broker the gateway owns to get an exclusive existing-Chrome lease. +type Client struct { + socketPath string + http *http.Client +} + +// NewClient does not itself verify the socket is reachable — call Acquire and +// handle ErrNotConnected; that is the fail-closed path callers must take. +func NewClient(socketPath string) *Client { + return &Client{ + socketPath: socketPath, + http: &http.Client{ + Timeout: 10 * time.Second, + Transport: &http.Transport{ + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + var d net.Dialer + return d.DialContext(ctx, "unix", socketPath) + }, + }, + }, + } +} + +// Reachable reports whether the socket exists and a gateway is actually +// listening on it — the check callers use to fail closed before ever trying +// to drive existing-Chrome, rather than surfacing a confusing connect error +// mid-task. +func (c *Client) Reachable() bool { + if _, err := os.Stat(c.socketPath); err != nil { + return false + } + resp, err := c.http.Get("http://broker/can_mutate?token=&page=") + if err != nil { + return false + } + resp.Body.Close() + return true +} + +func (c *Client) post(path string, in, out any) error { + body, err := json.Marshal(in) + if err != nil { + return err + } + resp, err := c.http.Post("http://broker"+path, "application/json", bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("%w: %v", ErrNotConnected, err) + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + var e struct{ Error string } + _ = json.NewDecoder(resp.Body).Decode(&e) + if e.Error == "" { + e.Error = resp.Status + } + return fmt.Errorf("%s", e.Error) + } + if out == nil { + return nil + } + return json.NewDecoder(resp.Body).Decode(out) +} + +// Acquire requests exclusive existing-Chrome mutation rights for (agentID, +// runID). Callers MUST fail closed on error — no ephemeral-browser fallback. +func (c *Client) Acquire(agentID, runID string, ttl time.Duration) (Lease, error) { + var lease Lease + err := c.post("/acquire", map[string]any{"AgentID": agentID, "RunID": runID, "TTLSeconds": int(ttl.Seconds())}, &lease) + return lease, err +} + +func (c *Client) Release(token string) error { + return c.post("/release", map[string]string{"Token": token}, nil) +} + +func (c *Client) OwnPage(token, page string) error { + return c.post("/own_page", map[string]string{"Token": token, "Page": page}, nil) +} + +func (c *Client) CanMutate(token, page string) bool { + resp, err := c.http.Get(fmt.Sprintf("http://broker/can_mutate?token=%s&page=%s", token, page)) + if err != nil { + return false + } + defer resp.Body.Close() + var out struct { + CanMutate bool `json:"can_mutate"` + } + _ = json.NewDecoder(resp.Body).Decode(&out) + return out.CanMutate +} diff --git a/internal/browser/broker/server.go b/internal/browser/broker/server.go new file mode 100644 index 0000000..2932081 --- /dev/null +++ b/internal/browser/broker/server.go @@ -0,0 +1,138 @@ +package broker + +import ( + "context" + "encoding/json" + "errors" + "net" + "net/http" + "os" + "path/filepath" + "time" + + gwconfig "github.com/memcode-ai/memcode/internal/gateway/config" +) + +// SocketPath is the well-known location of the gateway-owned existing-Chrome +// broker socket — shared between the gateway (which Serves it) and any +// process that dials it as a Client, including a autonomous agent's delegated +// worker running as a standalone `memcode run` job, not just inside the +// gateway. Its absence (no gateway running) is exactly the fail-closed signal +// existing-Chrome delegation must respect — see ErrNotConnected. +func SocketPath() (string, error) { + dir, err := gwconfig.Dir() + if err != nil { + return "", err + } + return filepath.Join(dir, "browser-broker.sock"), nil +} + +// Server exposes a Broker over a permission-protected local Unix socket, so a +// process OTHER than the one holding the *Broker* (a delegated worker, a +// separate OS process spawned via jobs.SpawnWithSpec) can Acquire/Release/ +// OwnPage/CanMutate against the SAME broker instance the gateway owns. The +// broker itself must stay a single, long-lived, in-process object — cloning +// it per connection would defeat its whole purpose (one lease, one owner, at +// a time, for the user's ONE real Chrome). +// +// The socket is created with 0600 permissions inside a 0700 directory (see +// gwconfig.Dir), so only the user who started the gateway can reach it — +// that ownership check is the "permission-protected" half of the design +// doc's "gateway-owned broker and permission-protected local socket". +type Server struct { + broker *Broker + listener net.Listener + http *http.Server +} + +// Serve starts listening on socketPath (removing any stale socket file left +// by a prior crashed gateway) and returns once the listener is up; Close +// stops it. b is the SAME *Broker instance the gateway's own in-process +// callers (if any) use — there is exactly one broker per gateway process. +func Serve(b *Broker, socketPath string) (*Server, error) { + _ = os.Remove(socketPath) // stale socket from a prior process; a live one would fail to bind anyway + ln, err := net.Listen("unix", socketPath) + if err != nil { + return nil, err + } + if err := os.Chmod(socketPath, 0o600); err != nil { + ln.Close() + return nil, err + } + mux := http.NewServeMux() + s := &Server{broker: b, listener: ln} + mux.HandleFunc("/acquire", s.handleAcquire) + mux.HandleFunc("/release", s.handleRelease) + mux.HandleFunc("/own_page", s.handleOwnPage) + mux.HandleFunc("/can_mutate", s.handleCanMutate) + s.http = &http.Server{Handler: mux} + go func() { _ = s.http.Serve(ln) }() + return s, nil +} + +func (s *Server) Close() error { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + err := s.http.Shutdown(ctx) + _ = os.Remove(s.listener.Addr().String()) + return err +} + +func writeJSON(w http.ResponseWriter, code int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(v) +} + +func (s *Server) handleAcquire(w http.ResponseWriter, r *http.Request) { + var in struct { + AgentID, RunID string + TTLSeconds int + } + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + ttl := time.Duration(in.TTLSeconds) * time.Second + if ttl <= 0 { + ttl = 5 * time.Minute + } + lease, err := s.broker.Acquire(in.AgentID, in.RunID, ttl) + if err != nil { + writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, lease) +} + +func (s *Server) handleRelease(w http.ResponseWriter, r *http.Request) { + var in struct{ Token string } + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]bool{"released": s.broker.Release(in.Token)}) +} + +func (s *Server) handleOwnPage(w http.ResponseWriter, r *http.Request) { + var in struct{ Token, Page string } + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + if err := s.broker.OwnPage(in.Token, in.Page); err != nil { + writeJSON(w, http.StatusForbidden, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]bool{"owned": true}) +} + +func (s *Server) handleCanMutate(w http.ResponseWriter, r *http.Request) { + token, page := r.URL.Query().Get("token"), r.URL.Query().Get("page") + writeJSON(w, http.StatusOK, map[string]bool{"can_mutate": s.broker.CanMutate(token, page)}) +} + +// ErrNotConnected is returned by a Client call when the socket itself is +// unreachable (no gateway running, or existing-Chrome never set up) — the +// caller's job is to fail closed on this, never to fall back to ephemeral. +var ErrNotConnected = errors.New("browser broker not reachable — is the gateway running with existing-Chrome configured?") diff --git a/internal/browser/broker/server_test.go b/internal/browser/broker/server_test.go new file mode 100644 index 0000000..2515332 --- /dev/null +++ b/internal/browser/broker/server_test.go @@ -0,0 +1,69 @@ +package broker + +import ( + "path/filepath" + "testing" + "time" +) + +// TestServerClientRoundTrip exercises the exact cross-process path a +// delegated worker uses: a Client talking over the Unix socket to a Server +// wrapping the gateway's *Broker*, not the in-process Broker methods +// directly. This is what makes existing-Chrome coordination possible across +// separate OS processes. +func TestServerClientRoundTrip(t *testing.T) { + b := New() + sock := filepath.Join(t.TempDir(), "broker.sock") + srv, err := Serve(b, sock) + if err != nil { + t.Fatal(err) + } + defer srv.Close() + + c := NewClient(sock) + if !c.Reachable() { + t.Fatal("expected socket to be reachable") + } + + lease, err := c.Acquire("agent-1", "run-1", time.Minute) + if err != nil { + t.Fatal(err) + } + if lease.Token == "" { + t.Fatal("expected a lease token") + } + + // A second, concurrent acquire must fail — exactly one worker may hold + // existing-Chrome mutation rights at a time. + if _, err := c.Acquire("agent-2", "run-2", time.Minute); err == nil { + t.Fatal("expected concurrent acquire to be rejected") + } + + if err := c.OwnPage(lease.Token, "tab-1"); err != nil { + t.Fatal(err) + } + if !c.CanMutate(lease.Token, "tab-1") { + t.Fatal("expected CanMutate to be true for the owning lease") + } + if c.CanMutate("wrong-token", "tab-1") { + t.Fatal("expected CanMutate to be false for a wrong token") + } + + if err := c.Release(lease.Token); err != nil { + t.Fatal(err) + } + // Released: a new run may now acquire. + if _, err := c.Acquire("agent-2", "run-2", time.Minute); err != nil { + t.Fatalf("expected acquire after release to succeed: %v", err) + } +} + +func TestClientNotReachableWhenNoServer(t *testing.T) { + c := NewClient(filepath.Join(t.TempDir(), "nonexistent.sock")) + if c.Reachable() { + t.Fatal("expected an unreachable socket to report not reachable") + } + if _, err := c.Acquire("a", "r", time.Minute); err == nil { + t.Fatal("expected Acquire to fail closed when the broker isn't running") + } +} diff --git a/internal/browser/controller.go b/internal/browser/controller.go new file mode 100644 index 0000000..6acd146 --- /dev/null +++ b/internal/browser/controller.go @@ -0,0 +1,11 @@ +package browser + +import "context" + +// Controller is the stable browser boundary shared by ephemeral and brokered +// backends. Calls remain typed; autonomous agents never receive raw MCP access. +type Controller interface { + Close() error + Navigate(context.Context, string) error + NewTab(context.Context, string) error +} diff --git a/internal/browser/ephemeral.go b/internal/browser/ephemeral.go new file mode 100644 index 0000000..3326601 --- /dev/null +++ b/internal/browser/ephemeral.go @@ -0,0 +1,6 @@ +package browser + +// EphemeralController identifies the existing fresh-profile backend used by +// ordinary sessions. Session remains the concrete implementation while callers +// migrate behind Controller. +type EphemeralController struct{ *Session } diff --git a/internal/browser/remote.go b/internal/browser/remote.go new file mode 100644 index 0000000..6b64343 --- /dev/null +++ b/internal/browser/remote.go @@ -0,0 +1,6 @@ +package browser + +const ChromeDevToolsMCPVersion = "1.8.0" +const ChromeDevToolsMCPPackage = "chrome-devtools-mcp@" + ChromeDevToolsMCPVersion + +type RemoteConfig struct{ SocketPath, AgentID, RunID, Token string } diff --git a/internal/doctrine/prompts.go b/internal/doctrine/prompts.go index 10a26c6..65f3646 100644 --- a/internal/doctrine/prompts.go +++ b/internal/doctrine/prompts.go @@ -398,6 +398,22 @@ read before acting, but do NOT assume it is complete or current — verify with }, "\n\n") case "plan": base = fmt.Sprintf(planBody, f("root"), f("platform"), f("overview")) + "\n\n" + freshnessDoctrine + "\n\n" + reuseDoctrine + case "autonomous": + // Domain-general executive for an agent running unattended. No repo root + // required — it operates over granted environment resources, not a checkout. + base = strings.Join([]string{ + `You are an agent's bounded executive advancing one long-lived objective, running with nobody watching, using only the authority an approved policy grants. + +Rules you must follow: +- Work only within the objective's approved policy and resource grants. Never exceed them. +- Every consequential action is journaled before it happens; prefer observe before mutate. +- You do not run continuously. Finish a bounded unit of work, then call report or schedule_wake. +- If you need information, approval, or a decision you lack, call ask_user and stop. +- Record durable knowledge with remember (it lands in memory.md and is known on every future wake, so you never ask the same thing twice). Break the objective into subgoals with subgoal_update. +- Never ask the user to do something you can do within your authority. Never act outside it. +- Be concise; this is one wake, not the whole objective.`, + f("state"), // objective, subgoals, facts summary injected as a fact + }, "\n\n") case "apply": // apply writes the most code of any mode, so it inherits the core laws and the // reuse-over-reinvent doctrine (chat/exec/plan already do). The approved plan stays @@ -650,7 +666,68 @@ Rules: - When a request is ambiguous (which channel, which sender id, what cron), use ask_user rather than guessing. - Sender access is by permanent user id, not @handle. If the user gives a handle, suggest pairing: the person messages the bot, and the user approves the code here. - Compose freely: "make me a research agent on Telegram that only Alice can use, with a 9am digest" is gw_agent + gw_channel (agent, allow_add) + gw_schedule, then edit the agent's MEMCODE.md for its standing instructions. -- Stay in scope: for coding tasks, point the user at the normal memcode session.` +- Stay in scope: for coding tasks, point the user at the normal memcode session. + +AGENTS THAT RUN ON THEIR OWN + +An agent can be given a durable objective and permission to pursue it +unattended. There is no separate kind of agent for this — it is the same +gw_agent, with more settings — and no separate place to manage it: you do all +of it here. + +Two settings, and they are SEPARATE grants you must propose separately: +- objective (gw_agent action=objective) — what the agent is for. +- autonomous (gw_agent action=autonomous) — whether it may act on that without + being asked. This is the one that matters: an unattended run cannot ask + permission mid-task, so it runs policy-gated, journals every consequential + action, and suspends durably on a question instead of prompting. Granting an + objective is not granting autonomy; say so, and confirm the second one on its + own. An agent may hold an objective you only ever work on together, and an + agent may run unattended on a schedule with no standing objective at all. + +The tools: gw_policy (stage/approve the authority it will use), gw_grant +(filesystem paths and other resources), gw_schedule (its cadence — set +agent= and leave deliver_to empty and the wake goes to the agent +itself), gw_wake (run one now), gw_inbox / gw_answer (questions it is +suspended on), gw_journal (what it actually did), gw_doctor (health), +gw_browser (check its access to the user's own Chrome). + +Setting one up is ONE guided conversation that ends with a working agent, not +a single tool call and not a pile of steps the user has to remember. When +someone says what they want an agent to do: + 1. GATHER: reason about what it will actually need — + - Resources: which filesystem paths (a resume, a tracking folder), which + toolsets (browser for job-board/email/site work — and if it needs + accounts the user is signed into, that means browser=existing_chrome, + their real Chrome, not a fresh logged-out profile; mcp servers; shell). + - Policy: which consequence classes — observe for reading, local_mutation + for keeping notes, external_effect or external_representation for + anything that acts or speaks on the user's behalf (submitting an + application, sending a message). + - Cadence: how it gets invoked from now on — a recurring gw_schedule, or + on-demand only via gw_wake. An agent nobody will ever wake is dead on + arrival, so decide this explicitly. + Ask (ask_user) about anything genuinely unclear rather than guessing at + scope — especially cadence and autonomy. Never silently pick "every five + minutes", and never grant autonomy the user did not ask for. + 2. PRESENT: lay the whole thing out in plain language before touching + anything — the objective as you understand it, each resource and why, + what the policy will allow, whether it will run unattended, its cadence, + and what stays out of scope. This is the review surface: the user should + finish reading it knowing exactly what authority and what standing + schedule they are about to hand over. + 3. APPLY: once they confirm (adjusting whatever they push back on), build it + completely — gw_agent add with the objective, gw_grant for each resource, + gw_policy stage then approve, gw_agent action=autonomous if that was + agreed, and gw_schedule for the cadence. Don't leave the schedule as a + "you can add this later" footnote when they were clear they wanted it. + Offer a first gw_wake if that fits. + 4. Never stage-and-approve a policy the user has not seen in plain language, + and never grant a resource, autonomy, or a cadence "just in case" beyond + what was asked. Narrower is correct — more can always be granted later. +A later single change (one more grant, a tightened policy, a different +cadence) is just that one call; the walkthrough is for the open-ended "here is +what I want, figure out what it needs" moment.` const recapDoctrine = `You recap recent work in ONE tight inline line — NOT a vertical bullet block. If the current session has meaningful activity, recap THAT; else the last meaningful session. Ground strictly in diff --git a/internal/gateway/config/config.go b/internal/gateway/config/config.go index d0ba216..652bb53 100644 --- a/internal/gateway/config/config.go +++ b/internal/gateway/config/config.go @@ -96,6 +96,43 @@ type Settings struct { // a project and NOT the `memcode run` CLI command — the agent's context is // composed and handed to the coding engine as generic supplemental context. type Agent struct { + // LegacyKind captures a removed `kind:` field so an old config fails LOUDLY + // instead of silently. `kind: personal` used to mean "this agent runs on its + // own"; autonomy is now an explicit setting. YAML ignores unknown fields, so + // without this the agent would quietly load as an ordinary one — still + // configured, apparently fine, and never waking again. Validate rejects it + // with the one-line fix. Never read this for behavior. + LegacyKind string `yaml:"kind,omitempty"` + // Objective is the durable outcome this agent works toward — the thing it + // is still pursuing between conversations. Empty for an ordinary + // conversational agent. + // + // Objective and Autonomous are deliberately ORTHOGONAL, because they answer + // different questions and conflating them was the original design mistake: + // - Objective — what am I trying to accomplish? + // - Autonomous — may I act on it without being prompted? + // An agent may hold an objective you only ever work on together (wakes on + // demand, never on its own), and an agent may run unattended on a schedule + // with no standing objective at all (see Autonomous). + Objective string `yaml:"objective,omitempty"` + // Autonomous marks this agent as permitted to run with nobody watching. It + // gates GOVERNANCE, not capability: an unattended run requires an approved + // delegation policy, journals its consequential actions, and suspends + // durably on a question instead of prompting a human who isn't there. + // + // This is what a plain cron-fired agent has always been missing — it runs + // unattended today with none of those protections — so the flag applies to + // any run of the agent, with or without an Objective. + Autonomous bool `yaml:"autonomous,omitempty"` + // Browser selects the backend for this agent's browser tools: "ephemeral" + // (default) launches a fresh, logged-out profile; "existing_chrome" + // attaches to the user's own already-running, already-signed-in Chrome + // through the gateway-owned broker. An agent acting on the user's behalf + // across their real accounts needs the latter; see internal/browser/broker. + Browser string `yaml:"browser,omitempty"` + // Paused stops future unattended wakes without deleting anything. On-demand + // runs still work. + Paused bool `yaml:"paused,omitempty"` // Model pins the model that drives this agent (an id from the catalog, // e.g. "claude-sonnet-5"). Empty = automatic routing. Wherever the agent // answers — any channel, any schedule — this is the model that serves it. @@ -392,12 +429,49 @@ func Load() (Settings, error) { if err := yaml.Unmarshal(b, &s); err != nil { return Settings{}, fmt.Errorf("parsing %s: %w", p, err) } + if err := s.Validate(); err != nil { + return Settings{}, fmt.Errorf("validating %s: %w", p, err) + } return s, nil } +// Validate checks additive configuration discriminators while preserving +// legacy zero values. +func (s Settings) Validate() error { + for id, agent := range s.Agents { + if agent.LegacyKind != "" { + return fmt.Errorf("agent %q still uses the removed `kind: %s` setting. Autonomy is now explicit: replace it with `autonomous: true` (and an `objective:` describing what it works toward) if this agent should keep running on its own, or just delete the `kind:` line if it should not. Its home under ~/.memcode/agents/%s is untouched either way", id, agent.LegacyKind, id) + } + if agent.Browser != "" && agent.Browser != BrowserEphemeral && agent.Browser != BrowserExistingChrome { + return fmt.Errorf("agent %q has unknown browser %q (want %s or %s)", id, agent.Browser, BrowserEphemeral, BrowserExistingChrome) + } + } + return nil +} + +// Browser backends for Agent.Browser. +const ( + // BrowserEphemeral is a fresh, logged-out Chrome profile per run — the + // default, and the right choice for anonymous browsing. + BrowserEphemeral = "ephemeral" + // BrowserExistingChrome attaches to the user's own running Chrome via the + // gateway-owned broker, inheriting their live sessions. Required for any + // task that acts inside accounts the user is signed into. + BrowserExistingChrome = "existing_chrome" +) + +// Unattended reports whether a run of this agent must be governed as +// unattended: policy-gated, action-journaled, and suspending durably rather +// than prompting. True whenever the agent is marked Autonomous — independent +// of whether it carries an Objective. +func (a Agent) Unattended() bool { return a.Autonomous } + // Save writes gateway.yaml atomically. 0600 — it holds no secrets, but the // allow-list of user ids is sensitive on a shared host, so keep it owner-only. func Save(s Settings) error { + if err := s.Validate(); err != nil { + return err + } p, err := Path() if err != nil { return err diff --git a/internal/gateway/config/config_test.go b/internal/gateway/config/config_test.go index b77d3a2..3c3827c 100644 --- a/internal/gateway/config/config_test.go +++ b/internal/gateway/config/config_test.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "reflect" + "strings" "testing" ) @@ -92,6 +93,42 @@ func TestAllowed(t *testing.T) { } } +func TestAgentAutonomyFieldsAndValidation(t *testing.T) { + // An ordinary agent stays valid and stays non-autonomous by default — + // autonomy is never acquired implicitly. + ordinary := Settings{Agents: map[string]Agent{"ordinary": {Model: "m"}}} + if err := ordinary.Validate(); err != nil { + t.Fatalf("ordinary agent must remain valid: %v", err) + } + if a := ordinary.Agents["ordinary"]; a.Autonomous || a.Unattended() || a.Objective != "" { + t.Fatalf("ordinary agent defaulted to autonomy: %+v", a) + } + + // Objective and Autonomous are independent: holding a goal is not + // permission to pursue it unprompted. + goalOnly := Agent{Objective: "find backend roles"} + if goalOnly.Unattended() { + t.Fatal("an objective alone must not make an agent unattended") + } + // ...and an agent may run unattended with no standing objective (scheduled + // work under governance), which is the case a single overloaded switch + // could not express. + scheduled := Agent{Autonomous: true} + if !scheduled.Unattended() { + t.Fatal("autonomous with no objective must still be governed as unattended") + } + + for _, br := range []string{"", BrowserEphemeral, BrowserExistingChrome} { + s := Settings{Agents: map[string]Agent{"a": {Browser: br}}} + if err := s.Validate(); err != nil { + t.Fatalf("browser %q rejected: %v", br, err) + } + } + bad := Settings{Agents: map[string]Agent{"a": {Browser: "safari"}}} + if err := bad.Validate(); err == nil { + t.Fatal("unknown browser backend accepted") + } +} func TestGetZeroValue(t *testing.T) { var s Settings // nil Channels map if got := s.Get("telegram"); !reflect.DeepEqual(got, Channel{}) { @@ -119,3 +156,19 @@ func TestPairingEnabledDefaults(t *testing.T) { t.Error("explicit telegram pairing:false ignored") } } + +// A removed setting must fail loudly, not vanish. `kind: personal` used to mean +// "runs on its own"; YAML would silently ignore it now, leaving an agent that +// looks configured but never wakes again. +func TestLegacyKindIsRejectedWithAFix(t *testing.T) { + s := Settings{Agents: map[string]Agent{"demo": {LegacyKind: "personal"}}} + err := s.Validate() + if err == nil { + t.Fatal("legacy kind silently accepted — an agent would quietly stop running") + } + for _, want := range []string{"autonomous: true", "objective:", "demo"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error should tell the user how to fix it; missing %q in: %v", want, err) + } + } +} diff --git a/internal/gateway/server/autonomy.go b/internal/gateway/server/autonomy.go new file mode 100644 index 0000000..599c73e --- /dev/null +++ b/internal/gateway/server/autonomy.go @@ -0,0 +1,181 @@ +package server + +import ( + "context" + "fmt" + "io" + "time" + + "github.com/memcode-ai/memcode/internal/agent/autonomy" + "github.com/memcode-ai/memcode/internal/channels" + gwconfig "github.com/memcode-ai/memcode/internal/gateway/config" + "github.com/memcode-ai/memcode/internal/llm" + "github.com/memcode-ai/memcode/internal/provider" +) + +// agentChannelName is the internal wake route for an agent running unattended. +// It has no external sender — output is journaled in the agent home — so byName +// gets a discard sink purely so Deliver can route and runJob can pick the work +// up. A schedule targets it with `deliver_to: agent:`, which is what lets +// the ORDINARY schedules: mechanism drive an autonomous wake instead of needing +// a second scheduler. +const agentChannelName = "agent" + +const agentRoutePrefix = "agent:" + +// hasAutonomousAgents reports whether any configured agent may run unattended. +func hasAutonomousAgents(settings gwconfig.Settings) bool { + for _, agent := range settings.Agents { + if agent.Autonomous { + return true + } + } + return false +} + +// autonomousWakeLoop fires an agent's own self-scheduled wakes — the ones it +// asked for from inside a run via schedule_wake ("come back in 45 minutes"). +// Human-authored recurring cadence does NOT come through here: that is an +// ordinary `schedules:` entry delivering to agent:, handled by +// applySchedules like every other schedule. Splitting them this way is what +// removes the second cron implementation while still letting an agent control +// its own timing. +// +// Claims are atomic (ClaimDueTrigger), so a fired wake advances its next_due +// and cannot double-fire across restarts or across two gateway processes. +// +// It keeps one open *autonomy.Store per agent for the life of the loop instead +// of opening and closing a connection (full PRAGMA setup + migration check) on +// every tick — that per-tick churn scaled with agent count and could make a +// tick's own wall time approach its own period. Only this goroutine touches the +// cache, so it needs no locking; stores are closed when ctx is done or an agent +// stops being autonomous. +func (r *runtime) autonomousWakeLoop(ctx context.Context) { + stores := map[string]*autonomy.Store{} + defer func() { + for _, st := range stores { + st.Close() + } + }() + tick := time.NewTicker(15 * time.Second) + defer tick.Stop() + for { + select { + case <-ctx.Done(): + return + case <-tick.C: + } + r.fireDueSelfWakes(ctx, stores) + } +} + +func (r *runtime) fireDueSelfWakes(ctx context.Context, stores map[string]*autonomy.Store) { + settings := r.cfg() + now := time.Now().UTC() + live := map[string]bool{} + for id, agent := range settings.Agents { + // Paused stops future unattended wakes without deleting anything; the + // store stays cached so resuming costs nothing. + if !agent.Autonomous { + continue + } + live[id] = true + if agent.Paused { + continue + } + st := stores[id] + if st == nil { + home, err := gwconfig.AgentHome(id) + if err != nil { + continue + } + st, err = autonomy.Open(ctx, home) + if err != nil { + continue + } + stores[id] = st + } + due, err := st.DueTriggers(ctx, now) + if err != nil { + continue + } + for _, t := range due { + // Atomic claim: only one gateway process advances the wake. + claimed, ok, err := st.ClaimDueTrigger(ctx, t.ID, now) + if err != nil || !ok { + continue + } + text := fmt.Sprintf("wake for %s (%s)", claimed.ID, claimed.Kind) + if err := r.enqueueAgentWake(ctx, id, text); err != nil { + fmt.Fprintf(r.out, "gateway: wake for %s failed: %v\n", id, err) + } + } + } + // An agent that stopped being autonomous since the last tick: close and drop + // its cached connection rather than leaking it. + for id, st := range stores { + if !live[id] { + st.Close() + delete(stores, id) + } + } +} + +func (r *runtime) enqueueAgentWake(ctx context.Context, agentID, text string) error { + a, ok := r.cfg().Agents[agentID] + if !ok || !a.Autonomous { + return fmt.Errorf("agent %q is not configured to run unattended", agentID) + } + return r.Deliver(ctx, channels.Inbound{Channel: agentChannelName, Conversation: agentID, Principal: agentRoutePrefix + agentID, Text: text, Trusted: true, MessageID: fmt.Sprintf("wake-%d", time.Now().UnixNano())}) +} + +// agentSink is the discard reply target for the internal agent-wake channel: +// an unattended run's output is journaled in the agent home, so there is +// nothing to send anywhere. +type agentSink struct{ out io.Writer } + +func (agentSink) Name() string { return agentChannelName } +func (s agentSink) Send(ctx context.Context, _ string, ob channels.Outbound) error { + fmt.Fprintf(s.out, "gateway: agent: %s\n", truncate(ob.Text, 120)) + return nil +} + +// runAutonomousWake executes one unattended wake inline and returns its report +// as the (discarded) reply. Fails closed: an agent that is not autonomous, or +// has no approved policy, gets a blocked report rather than a run. +func (r *runtime) runAutonomousWake(ctx context.Context, agentID string) string { + a, ok := r.cfg().Agents[agentID] + if !ok || !a.Autonomous { + return "[blocked] agent is not configured to run unattended" + } + if a.Paused { + return "[blocked] agent is paused" + } + home, err := gwconfig.AgentHome(agentID) + if err != nil { + return "error: " + err.Error() + } + st, err := autonomy.Open(ctx, home) + if err != nil { + return "error: " + err.Error() + } + defer st.Close() + // Fail-closed FIRST: report blocked before constructing a model, so a missing + // policy surfaces as policy (not a model/auth error) in the gateway log. + if _, hasPol, err := st.ApprovedPolicy(ctx, "primary"); err != nil { + return "error: " + err.Error() + } else if !hasPol { + return "[blocked] no approved policy" + } + provider.LoadDotEnv() + prov, err := provider.NewFromEnv() + if err != nil { + return "error: no model configured: " + err.Error() + } + ex := &autonomy.Executive{Store: st, Home: home, AgentID: agentID, Objective: a.Objective, Runner: llm.NewRunner(prov)} + out, err := ex.RunOnce(ctx) + if err != nil { + return "error: " + err.Error() + } + return fmt.Sprintf("[%s] %s", out.Status, out.Report) +} diff --git a/internal/gateway/server/scheduler_test.go b/internal/gateway/server/scheduler_test.go index 5b40e81..4043eb5 100644 --- a/internal/gateway/server/scheduler_test.go +++ b/internal/gateway/server/scheduler_test.go @@ -11,6 +11,22 @@ import ( "github.com/memcode-ai/memcode/internal/gateway/state" ) +func TestHasAutonomousAgents(t *testing.T) { + if hasAutonomousAgents(gwconfig.Settings{}) { + t.Fatal("empty settings reported autonomous agents") + } + if hasAutonomousAgents(gwconfig.Settings{Agents: map[string]gwconfig.Agent{"ordinary": {}}}) { + t.Fatal("ordinary agent reported as autonomous") + } + // An objective alone is NOT autonomy — the wake loop must not pick this up. + if hasAutonomousAgents(gwconfig.Settings{Agents: map[string]gwconfig.Agent{"goal": {Objective: "do a thing"}}}) { + t.Fatal("an objective alone made an agent autonomous") + } + if !hasAutonomousAgents(gwconfig.Settings{Agents: map[string]gwconfig.Agent{"executive": {Autonomous: true}}}) { + t.Fatal("autonomous agent not discovered") + } +} + type fakeSender struct{} func (fakeSender) Send(context.Context, string, channels.Outbound) error { return nil } diff --git a/internal/gateway/server/server.go b/internal/gateway/server/server.go index 243fd00..eea5a75 100644 --- a/internal/gateway/server/server.go +++ b/internal/gateway/server/server.go @@ -24,6 +24,7 @@ import ( "github.com/robfig/cron/v3" "github.com/memcode-ai/memcode/internal/agent/permissions" + "github.com/memcode-ai/memcode/internal/browser/broker" "github.com/memcode-ai/memcode/internal/channels" "github.com/memcode-ai/memcode/internal/channels/discord" "github.com/memcode-ai/memcode/internal/channels/email" @@ -79,6 +80,17 @@ type runtime struct { out io.Writer notify chan struct{} // wakes the worker when a message is accepted + // browserBroker arbitrates exclusive mutation rights over the user's + // existing (already-running, already-logged-in) Chrome, so at most one + // delegated autonomous agent worker drives it at a time. It is a SINGLE + // object for the gateway's whole lifetime — that persistence is the point: + // a worker on wake N and a different worker on wake N+1 reach the SAME + // broker, not a fresh one, so ownership/leasing state survives across + // wakes. brokerServer exposes it over a local socket so a worker (a + // separate OS process, see jobs.SpawnWithSpec) can reach it too. + browserBroker *broker.Broker + brokerServer *broker.Server + // sched is the live schedule runner (recurring entries), timers the pending // one-shots, and schedList the schedules both were built from (for change // detection on reload). All are touched only from the Run/worker goroutine, @@ -141,17 +153,33 @@ func Run(ctx context.Context, root string, mainStore store.Store, settings gwcon }() rt := &runtime{ - root: root, - gw: gw, - mainStore: mainStore, - settings: settings, - mediaDir: mediaDir, - stt: newTranscriber(), - tts: newSpeaker(), - byName: make(map[string]replySender, 4), - disp: newDispatcher(), - out: out, - notify: make(chan struct{}, 1), + root: root, + gw: gw, + mainStore: mainStore, + settings: settings, + mediaDir: mediaDir, + stt: newTranscriber(), + tts: newSpeaker(), + byName: make(map[string]replySender, 4), + disp: newDispatcher(), + out: out, + notify: make(chan struct{}, 1), + browserBroker: broker.New(), + } + // Existing-Chrome coordination socket: started unconditionally (cheap — a + // local listener) so it's there the moment an autonomous agent's delegate + // call needs it, without requiring a gateway restart after existing-Chrome + // is set up. Its failure is non-fatal to the gateway as a + // whole — a delegated worker that needs it fails closed on its own when + // it can't reach the socket, per design; it never silently falls back to + // ephemeral Chrome. + if sock, err := broker.SocketPath(); err == nil { + if srv, err := broker.Serve(rt.browserBroker, sock); err == nil { + rt.brokerServer = srv + defer srv.Close() + } else { + fmt.Fprintf(out, "gateway: browser broker socket unavailable: %v (existing-Chrome delegation will fail closed)\n", err) + } } // Register EVERY sender in byName before any goroutine that reads it exists: the channel @@ -162,9 +190,19 @@ func Run(ctx context.Context, root string, mainStore store.Store, settings gwcon for _, ch := range chs { rt.byName[ch.Name()] = ch } + // An unattended agent wake has an internal route: no external sender, output + // is journaled to the agent home, so register a discard sink so Deliver can + // route it. Registered unconditionally (not gated on hasAutonomousAgents at + // boot) because byName is built once here and never mutated again — an agent + // made autonomous later via a hot-reloaded config must still have somewhere + // for its wakes to go without requiring a gateway restart. + rt.byName[agentChannelName] = agentSink{out: out} webhooks := startWebhooks(ctx, settings, rt, out) + if len(chs) == 0 && !webhooks && !hasAutonomousAgents(settings) { + return fmt.Errorf("no channels or autonomous agents configured — run `memcode gateway setup`, or `memcode admin` to set an agent up to run on its own") + } if len(chs) == 0 && !webhooks { - return fmt.Errorf("no channels configured — run `memcode gateway setup` to add one") + fmt.Fprintln(out, "gateway: running locally for autonomous agents (no external channels configured)") } for _, ch := range chs { ch := ch @@ -177,6 +215,10 @@ func Run(ctx context.Context, root string, mainStore store.Store, settings gwcon } rt.applySchedules(ctx) // time-triggered tasks feed the same inbox + // Always run the self-wake loop, even with no autonomous agents at boot: it + // re-reads settings via r.cfg() every tick, so an agent made autonomous + // later through a hot-reloaded config is picked up without a restart. + go rt.autonomousWakeLoop(ctx) // agent-authored next-wakes feed the same inbox rt.runWorker(ctx) // blocks until ctx is cancelled if rt.sched != nil { @@ -531,6 +573,17 @@ func (r *runtime) runJob(ctx context.Context, it state.Item) { _ = r.gw.MarkDone(ctx, it.Channel, it.MessageID) return } + // An unattended wake runs the bounded executive inline (not a detached coding + // job): the executive owns its policy gate, journal, and continuation state. + if it.Channel == agentChannelName { + report := r.runAutonomousWake(ctx, it.Conversation) // conversation = agent id + if err := r.gw.SetReplied(ctx, it.Channel, it.MessageID, report, ""); err != nil { + fmt.Fprintf(r.out, "gateway: recording agent wake for %s failed: %v\n", it.Conversation, err) + return + } + r.deliverReply(ctx, it, report) // agentSink discards to the log + return + } // A gateway-triggered job has no TTY to answer approval prompts → Auto mode. // Continuity: a stable session id per conversation, so follow-up messages // resume the same session (the child does resume-or-create on this id). Tier diff --git a/internal/gateway/state/state.go b/internal/gateway/state/state.go index 8acc0fb..558dc16 100644 --- a/internal/gateway/state/state.go +++ b/internal/gateway/state/state.go @@ -31,7 +31,7 @@ CREATE TABLE IF NOT EXISTS inbox ( principal TEXT NOT NULL, text TEXT NOT NULL, trusted INTEGER NOT NULL, - status TEXT NOT NULL, -- 'pending' | 'replied' | 'done' + status TEXT NOT NULL, -- pending | running | waiting | resumable | replied | done reply TEXT NOT NULL DEFAULT '', -- the job's result, held durably until delivered agent TEXT NOT NULL DEFAULT '', -- agent snapshot at receipt (immutable for this task) project TEXT NOT NULL DEFAULT '', -- project id snapshot at receipt (immutable for this task) @@ -238,6 +238,15 @@ func (s *Store) Accept(ctx context.Context, it Item, now time.Time) (bool, error // Pending returns the still-to-process items, oldest first. Used to feed the // worker and, on startup, to replay anything a prior crash left unprocessed. +func (s *Store) SetInboxStatus(ctx context.Context, channel, messageID, from, to string) (bool, error) { + res, err := s.db.ExecContext(ctx, `UPDATE inbox SET status=? WHERE channel=? AND message_id=? AND status=?`, to, channel, messageID, from) + if err != nil { + return false, err + } + n, err := res.RowsAffected() + return n == 1, err +} + func (s *Store) Pending(ctx context.Context) ([]Item, error) { rows, err := s.db.QueryContext(ctx, `SELECT channel, message_id, conversation, principal, text, trusted, agent, project, attachments diff --git a/internal/gateway/state/state_test.go b/internal/gateway/state/state_test.go index 4c7af01..35fb7db 100644 --- a/internal/gateway/state/state_test.go +++ b/internal/gateway/state/state_test.go @@ -20,6 +20,20 @@ func item(channel, id string) Item { return Item{Channel: channel, MessageID: id, Conversation: "c", Principal: "p", Text: "hi"} } +func TestInboxWaitingTransitions(t *testing.T) { + s := openTemp(t) + ctx := context.Background() + if _, err := s.Accept(ctx, item("personal", "m1"), time.Now()); err != nil { + t.Fatal(err) + } + for _, tr := range [][2]string{{"pending", "running"}, {"running", "waiting"}, {"waiting", "resumable"}, {"resumable", "replied"}, {"replied", "done"}} { + ok, err := s.SetInboxStatus(ctx, "personal", "m1", tr[0], tr[1]) + if err != nil || !ok { + t.Fatalf("%s→%s ok=%v err=%v", tr[0], tr[1], ok, err) + } + } +} + func TestAcceptDedup(t *testing.T) { s := openTemp(t) ctx := context.Background() diff --git a/internal/guard/singletons_test.go b/internal/guard/singletons_test.go new file mode 100644 index 0000000..a4f2739 --- /dev/null +++ b/internal/guard/singletons_test.go @@ -0,0 +1,140 @@ +package guard + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// These guards protect an invariant that was violated once and cost real bugs: +// the Personal Agents subsystem grew a parallel implementation of machinery the +// ordinary agent system already had — a second cron parser, a second (and +// third) suspend/resume design, a second cockpit — and the two paths drifted. +// Fixes landed on one side and not the other. The consolidation removed the +// duplicates; these tests keep them from quietly coming back. + +// goFiles walks the module's own Go sources, skipping vendored forks, tests, +// and this guard package itself. +func goFiles(t *testing.T, skipTests bool) map[string]string { + t.Helper() + root, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + root = filepath.Dir(filepath.Dir(root)) // internal/guard -> module root + out := map[string]string{} + err = filepath.Walk(root, func(p string, info os.FileInfo, err error) error { + if err != nil { + return nil // unreadable paths (symlinked node_modules) are not our concern + } + if info.IsDir() { + switch info.Name() { + case "node_modules", "forks", ".git", ".memcode", "desktop": + return filepath.SkipDir + } + return nil + } + if !strings.HasSuffix(p, ".go") { + return nil + } + if skipTests && strings.HasSuffix(p, "_test.go") { + return nil + } + if strings.Contains(p, "internal/guard/") { + return nil + } + b, rerr := os.ReadFile(p) + if rerr != nil { + return nil + } + rel, _ := filepath.Rel(root, p) + out[rel] = string(b) + return nil + }) + if err != nil { + t.Fatal(err) + } + if len(out) == 0 { + t.Fatal("walked no Go files — the guard would pass vacuously") + } + return out +} + +// TestSingleCronParser: exactly one package parses cron expressions. An +// autonomous agent's recurring cadence is an ordinary schedule, not a second +// scheduling system. +func TestSingleCronParser(t *testing.T) { + var users []string + for path, src := range goFiles(t, true) { + if strings.Contains(src, "robfig/cron") { + users = append(users, filepath.Dir(path)) + } + } + seen := map[string]bool{} + var pkgs []string + for _, d := range users { + if !seen[d] { + seen[d] = true + pkgs = append(pkgs, d) + } + } + // The gateway owns scheduling: the cron runner and the shared spec + // validation both live under internal/gateway. + for _, p := range pkgs { + if !strings.HasPrefix(p, "internal/gateway/") { + t.Errorf("%s parses cron — scheduling belongs to internal/gateway (one scheduler, reached via gw_schedule); a per-subsystem parser is how the two schedulers drifted apart", p) + } + } +} + +// TestSingleSuspensionImplementation: durable suspend/resume lives in exactly +// one package. Three partial designs coexisted before this — one unused, one +// never written to, one hand-rolled and (briefly) not crash-safe. +func TestSingleSuspensionImplementation(t *testing.T) { + const impl = "internal/agent/continuation/" + for path, src := range goFiles(t, true) { + if strings.HasPrefix(path, impl) { + continue + } + // Marker of a bespoke continuation file format: writing a suspension + // blob rather than going through the shared package. + if strings.Contains(src, `"suspension-"`) || strings.Contains(src, `"tool_use_id":`) && strings.Contains(src, `"resolved"`) { + t.Errorf("%s appears to hand-roll a suspension file format — use internal/agent/continuation instead", path) + } + } +} + +// TestNoSecondCockpit: agents are managed through the admin surface. A second +// interactive management console means a second set of handlers, and the two +// drift (the config mirror was written on one path and not the other). +func TestNoSecondCockpit(t *testing.T) { + for path, src := range goFiles(t, false) { + if strings.Contains(src, "SetPersonal(") || strings.Contains(src, "personalMode") { + t.Errorf("%s references the removed personal cockpit — agent management belongs to the admin tools (gw_*)", path) + } + if strings.Contains(src, `"pa_`) { + t.Errorf("%s references a pa_* tool — those folded into the gw_* registry", path) + } + } +} + +// TestNoAgentKind: autonomy is orthogonal settings on an agent, never a "kind" +// discriminator. A kind field is what made Personal a separate species. +// +// gwconfig.Agent.LegacyKind is the one allowed mention: it exists solely so an +// old `kind: personal` config is REJECTED with a fix rather than silently +// ignored by YAML. Behavior must never branch on it, so the check below looks +// for the branch, not the name. +func TestNoAgentKind(t *testing.T) { + for path, src := range goFiles(t, false) { + for _, line := range strings.Split(src, "\n") { + if strings.Contains(line, "LegacyKind") { + continue + } + if strings.Contains(line, `Kind: "personal"`) || strings.Contains(line, `Kind == "personal"`) || strings.Contains(line, `kind == "personal"`) { + t.Errorf("%s still discriminates on an agent kind — use Agent.Autonomous / Agent.Objective:\n %s", path, strings.TrimSpace(line)) + } + } + } +} diff --git a/internal/interaction/types.go b/internal/interaction/types.go new file mode 100644 index 0000000..e0bed9a --- /dev/null +++ b/internal/interaction/types.go @@ -0,0 +1,36 @@ +package interaction + +import ( + "encoding/json" + "time" +) + +type Kind string + +const ( + Question Kind = "question" + Approval Kind = "approval" + EnvironmentHandoff Kind = "environment_handoff" + Challenge Kind = "challenge" + MissingInformation Kind = "missing_information" + PolicyException Kind = "policy_exception" +) + +type Status string + +const ( + Pending Status = "pending" + Answered Status = "answered" + Cancelled Status = "cancelled" + Expired Status = "expired" +) + +type Interaction struct { + ID, RunID, JobID, SessionID, Channel, Conversation, ToolUseID string + Kind Kind + Request, Response, Continuation json.RawMessage + Status Status + PolicyVersion int + CreatedAt time.Time + ExpiresAt, AnsweredAt, CancelledAt *time.Time +} diff --git a/internal/jobs/jobs.go b/internal/jobs/jobs.go index 5120de4..0f8fd9d 100644 --- a/internal/jobs/jobs.go +++ b/internal/jobs/jobs.go @@ -28,12 +28,34 @@ import ( // Status values for a job. const ( StatusRunning = "running" + StatusWaiting = "waiting" StatusDone = "done" StatusFailed = "failed" StatusStopped = "stopped" // process gone but never recorded a finish ) // Job is one background agent session. +type ToolPolicy struct { + Allowed []string `json:"allowed,omitempty"` + Disabled []string `json:"disabled,omitempty"` +} +type ResourceGrant struct { + IDs []string `json:"ids,omitempty"` +} +type ExecutionBudgets struct { + MaxSeconds int `json:"max_seconds,omitempty"` + MaxToolCalls int `json:"max_tool_calls,omitempty"` + MaxDelegationDepth int `json:"max_delegation_depth,omitempty"` +} + +type SpawnSpec struct { + Root, Task, Mode, Tier, SessionID, AgentID, ObjectiveID, SubgoalID, RunID, ParentRunID, PolicyHash, BrowserMode string + ToolPolicy ToolPolicy + ResourceGrant ResourceGrant + Budgets ExecutionBudgets + ReportBack bool +} + type Job struct { ID string `json:"id"` Task string `json:"task"` @@ -54,10 +76,25 @@ type Job struct { FinishedAt time.Time `json:"finished_at,omitempty"` // Live readout, heartbeated by the running child (~1s) so frontends can show // what a detached agent is doing right now. Additive; absent in old metas. - Activity string `json:"activity,omitempty"` // latest tool label, e.g. "bash(go test ./...)" - TokensIn int64 `json:"tokens_in,omitempty"` // child session input tokens so far - TokensOut int64 `json:"tokens_out,omitempty"` // child session output tokens so far - HeartbeatAt time.Time `json:"heartbeat_at,omitempty"` + Activity string `json:"activity,omitempty"` // latest tool label, e.g. "bash(go test ./...)" + TokensIn int64 `json:"tokens_in,omitempty"` // child session input tokens so far + TokensOut int64 `json:"tokens_out,omitempty"` // child session output tokens so far + HeartbeatAt time.Time `json:"heartbeat_at,omitempty"` + AgentID string `json:"agent_id,omitempty"` + ObjectiveID string `json:"objective_id,omitempty"` + SubgoalID string `json:"subgoal_id,omitempty"` + RunID string `json:"run_id,omitempty"` + ParentRunID string `json:"parent_run_id,omitempty"` + SessionID string `json:"session_id,omitempty"` + PolicyHash string `json:"policy_hash,omitempty"` + ExecutionEnvelope json.RawMessage `json:"execution_envelope,omitempty"` + // NOTE: this struct deliberately carries no suspension/continuation fields. + // It used to declare InteractionID/WaitingReason/ContinuationVersion/ + // WaitingAt/ResumedAt, which nothing ever wrote — a third half-built + // suspend/resume design alongside two others. Durable suspension lives in + // internal/agent/continuation, once. A detached job child runs with + // SetNoApprover and cannot ask a human mid-run today; if that changes, wire + // it to that package rather than re-adding fields here. } // processMatches reports whether the job's recorded pid is alive AND still the same process @@ -92,6 +129,17 @@ func LogPath(root, id string) string { return filepath.Join(jobDir(root, id), "l // When chrome is true, --chrome is forwarded so backgrounded browser jobs keep // the capability (Chrome always launches with a visible window). func Spawn(root, task, mode, tier string, chrome, reportBack bool, session string) (Job, error) { + browserMode := "" + if chrome { + browserMode = "ephemeral" + } + return SpawnWithSpec(SpawnSpec{Root: root, Task: task, Mode: mode, Tier: tier, SessionID: session, BrowserMode: browserMode, ReportBack: reportBack}) +} + +func SpawnWithSpec(spec SpawnSpec) (Job, error) { + root, task, mode, tier, reportBack, session := spec.Root, spec.Task, spec.Mode, spec.Tier, spec.ReportBack, spec.SessionID + chrome := spec.BrowserMode == "ephemeral" + existingChrome := spec.BrowserMode == "existing_chrome" self, err := os.Executable() if err != nil { return Job{}, fmt.Errorf("locating memcode binary: %w", err) @@ -116,9 +164,28 @@ func Spawn(root, task, mode, tier string, chrome, reportBack bool, session strin if chrome { argv = append(argv, "--chrome") } + if existingChrome { + // The child dials the gateway-owned browser broker itself (socket path + // is well-known, see internal/browser/broker.SocketPath), authenticating + // the lease request as (AgentID, this job's own id) — a job id is unique + // per delegate call, so it doubles as the lease's RunID. + argv = append(argv, "--browser-session", "existing_chrome", "--browser-agent", spec.AgentID, "--browser-run", id) + } if session != "" { argv = append(argv, "--session", session) // continue this conversation's session (resume-or-create) } + // ToolPolicy is a REAL restriction on the child, not just recorded metadata: + // --allow-tools/--deny-tools bind the same SetToolPolicy enforcement an + // ordinary gateway-bound agent gets from its config. A caller (e.g. a + // autonomous agent's delegate tool) that hands this spec a narrower toolset + // than the parent policy allows gets an actually narrower child, not just an + // audited claim of one. + if len(spec.ToolPolicy.Allowed) > 0 { + argv = append(argv, "--allow-tools", strings.Join(spec.ToolPolicy.Allowed, ",")) + } + if len(spec.ToolPolicy.Disabled) > 0 { + argv = append(argv, "--deny-tools", strings.Join(spec.ToolPolicy.Disabled, ",")) + } if isTestBinary(self) { // Under `go test`, os.Executable() is the package's TEST binary, not memcode. // Re-execing it as `agent …` runs the caller's whole test suite again: the @@ -144,6 +211,7 @@ func Spawn(root, task, mode, tier string, chrome, reportBack bool, session strin // Detach: release the child so it keeps running after we return. _ = cmd.Process.Release() + envelope, _ := json.Marshal(spec) job := Job{ ID: id, Task: task, @@ -154,6 +222,9 @@ func Spawn(root, task, mode, tier string, chrome, reportBack bool, session strin StartSig: sig, Status: StatusRunning, StartedAt: time.Now().UTC(), + AgentID: spec.AgentID, ObjectiveID: spec.ObjectiveID, SubgoalID: spec.SubgoalID, + RunID: spec.RunID, ParentRunID: spec.ParentRunID, SessionID: spec.SessionID, + PolicyHash: spec.PolicyHash, ExecutionEnvelope: envelope, } if err := writeMeta(root, job); err != nil { return Job{}, err diff --git a/internal/jobs/jobs_test.go b/internal/jobs/jobs_test.go index b34e3c2..6c3f447 100644 --- a/internal/jobs/jobs_test.go +++ b/internal/jobs/jobs_test.go @@ -9,6 +9,30 @@ import ( "time" ) +func TestSpawnWithSpecCompatibility(t *testing.T) { + root := t.TempDir() + job, err := SpawnWithSpec(SpawnSpec{Root: root, Task: "inspect", Mode: "auto", Tier: "strong", SessionID: "session-1", AgentID: "agent-1", ObjectiveID: "objective-1", SubgoalID: "subgoal-1", RunID: "run-1", ParentRunID: "parent-1", PolicyHash: "hash-1", ToolPolicy: ToolPolicy{Allowed: []string{"files"}}, ResourceGrant: ResourceGrant{IDs: []string{"resource-1"}}, Budgets: ExecutionBudgets{MaxSeconds: 30}, ReportBack: true}) + if err != nil { + t.Fatal(err) + } + if job.AgentID != "agent-1" || job.ObjectiveID != "objective-1" || job.SessionID != "session-1" || job.PolicyHash != "hash-1" { + t.Fatalf("job=%+v", job) + } + if job.Status != StatusRunning || len(job.ExecutionEnvelope) == 0 { + t.Fatalf("job=%+v", job) + } +} + +func TestLegacySpawnWrapper(t *testing.T) { + job, err := Spawn(t.TempDir(), "inspect", "auto", "", false, false, "legacy-session") + if err != nil { + t.Fatal(err) + } + if job.SessionID != "legacy-session" || job.Task != "inspect" { + t.Fatalf("job=%+v", job) + } +} + func TestMetaRoundTripListFinish(t *testing.T) { root := t.TempDir() job := Job{ID: "job_test", Task: "do a thing", Mode: "auto", PID: os.Getpid(), diff --git a/internal/vxui/app.go b/internal/vxui/app.go index e00ae46..e8cec02 100644 --- a/internal/vxui/app.go +++ b/internal/vxui/app.go @@ -697,7 +697,7 @@ func (s *appState) submit(line string) { // Use the EXPANDED text (t), not the raw line: a slash command whose args include a // paste (e.g. `/plan `) must get the real content, not the `[pasted #n]` // token — the raw line still carries the placeholder (and s.pastes is cleared above). - if strings.HasPrefix(t, "/") && isKnownSlash(t, s.w.sess.Admin()) { + if strings.HasPrefix(t, "/") && isKnownSlash(t, s.w.sess.Restricted()) { // Echo the typed command into scrollback BEFORE dispatching — same prompt style as a // chat turn — so `/model`, `/theme`, etc. leave a trace of what was invoked instead of // only the bare confirmation line ("model → sonnet" with no idea what command ran it). @@ -960,7 +960,7 @@ func skippedRule(n, width int) string { // menu returns the slash autocomplete matches when the composer is a bare "/prefix". func (s *appState) menu() []slashCmd { if strings.HasPrefix(s.composer, "/") && !strings.ContainsRune(s.composer, ' ') { - return matchSlash(s.composer, s.w.sess.Admin()) + return matchSlash(s.composer, s.w.sess.Restricted()) } return nil } diff --git a/internal/vxui/commands.go b/internal/vxui/commands.go index 750b58a..6c4c401 100644 --- a/internal/vxui/commands.go +++ b/internal/vxui/commands.go @@ -38,7 +38,7 @@ func (s *appState) runSlash(line string) (quit bool) { case "/quit": return true case "/help": - s.sysln(slashHelp(s.w.sess.Admin())) + s.sysln(slashHelp(s.w.sess.Restricted())) case "/login": s.loginSlash() case "/logout":