From 9b027bb292dc47a7737259eed5a950927127e9f4 Mon Sep 17 00:00:00 2001 From: Tim Erwin Date: Sun, 30 Aug 2026 13:06:30 +0700 Subject: [PATCH 01/13] personal: add Personal Agents (durable objectives, delegation policy, triggers, browser broker) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the Personal Agents feature: long-lived agent identities with their own durable objective, subgoals, and facts store; a delegation policy model (allowed tools, consequence classes, filesystem/browser/MCP grants, budgets) that governs what a Personal Agent's executive loop may do; ask_user suspend/resume for human-in-the-loop interactions; durable triggers (cron/interval/one-shot) polled by the gateway to wake an agent; resource grants (filesystem, mcp, command, channel); and a browser broker that leases exclusive browser control to one run at a time. New surfaces: `memcode personal` CLI (create, approve-policy, resources, triggers, answer, status, cockpit), gw_agent/gw_* admin tools, and the gateway's internal personal wake route. Includes fixes from review of this branch: - personal answer refuses to re-resume an already-answered interaction, and a resumed run that re-suspends now marks its prior continuation resolved — closing a window where a retried answer could replay tool side effects. - The gateway always registers the personal wake route and always starts the trigger poll loop, so a Personal Agent added after boot has its triggers picked up without a restart. - Browser broker leases now check expiry in CanMutate/OwnPage, matching Authenticate, so an expired lease can no longer authorize mutation. - Resource grant IDs use a monotonic timestamp instead of locator length, avoiding same-length-locator collisions. - Delegation policy narrowing (IsRestriction/NarrowPolicy) now also checks filesystem roots, browser origins, MCP tools, budget fields, and quiet hours, not just tools/consequence-classes/concurrency/depth. - `personal triggers pause/resume` errors on an unknown trigger id instead of reporting success silently. - gw_agent's description no longer advertises the unsupported "inspect" action. - Suspension continuation files are written atomically. - The trigger poll loop reuses one DB connection per agent across ticks and filters due triggers in SQL, instead of opening/closing a connection and scanning every trigger row every 15s. --- README.md | 8 +- cmd/admin_tools.go | 13 +- cmd/personal.go | 330 ++++++++++ cmd/personal_cmd_test.go | 206 ++++++ cmd/personal_cockpit.go | 428 +++++++++++++ cmd/personal_policy.go | 122 ++++ cmd/personal_resources.go | 86 +++ cmd/personal_status.go | 111 ++++ cmd/personal_test.go | 61 ++ cmd/personal_tools.go | 5 + cmd/personal_triggers.go | 93 +++ docs/design/personal-agents.md | 159 +++++ docs/gateway/README.md | 7 + docs/personal-agents.md | 55 ++ internal/agent/runtime/admin.go | 44 ++ internal/agent/runtime/continuation.go | 104 +++ internal/agent/runtime/continuation_test.go | 54 ++ internal/agent/runtime/exec.go | 13 + internal/agent/runtime/prompts.go | 3 + internal/agent/runtime/runtime.go | 15 + internal/agent/tools/admin.go | 3 +- internal/agent/tools/personal.go | 111 ++++ internal/browser/broker/broker.go | 75 +++ internal/browser/broker/broker_test.go | 38 ++ internal/browser/controller.go | 11 + internal/browser/ephemeral.go | 6 + internal/browser/remote.go | 6 + internal/doctrine/prompts.go | 33 + internal/gateway/config/config.go | 21 + internal/gateway/config/config_test.go | 20 + internal/gateway/server/personal.go | 157 +++++ internal/gateway/server/scheduler_test.go | 12 + internal/gateway/server/server.go | 27 +- internal/gateway/state/state.go | 18 +- internal/gateway/state/state_test.go | 14 + internal/interaction/types.go | 36 ++ internal/jobs/jobs.go | 57 +- internal/jobs/jobs_test.go | 24 + internal/personal/action.go | 100 +++ internal/personal/action_test.go | 44 ++ internal/personal/crud.go | 340 ++++++++++ internal/personal/delegation.go | 57 ++ internal/personal/delegation_test.go | 37 ++ internal/personal/environment.go | 15 + internal/personal/executive.go | 64 ++ internal/personal/executive_test.go | 56 ++ internal/personal/facts.go | 17 + internal/personal/generated.go | 83 +++ internal/personal/interactions.go | 111 ++++ .../personal/migrations/002_interactions.sql | 7 + internal/personal/model.go | 77 +++ internal/personal/pacing.go | 68 ++ internal/personal/pacing_test.go | 37 ++ internal/personal/policy.go | 136 ++++ internal/personal/policy_test.go | 55 ++ internal/personal/resources.go | 104 +++ internal/personal/resources_test.go | 79 +++ internal/personal/runner.go | 106 ++++ internal/personal/runner_exec.go | 600 ++++++++++++++++++ internal/personal/runner_exec_test.go | 247 +++++++ internal/personal/runner_test.go | 65 ++ internal/personal/scheduler.go | 169 +++++ internal/personal/schema.sql | 54 ++ internal/personal/store.go | 244 +++++++ internal/personal/store_test.go | 190 ++++++ internal/vxui/app.go | 4 +- internal/vxui/commands.go | 2 +- 67 files changed, 5742 insertions(+), 12 deletions(-) create mode 100644 cmd/personal.go create mode 100644 cmd/personal_cmd_test.go create mode 100644 cmd/personal_cockpit.go create mode 100644 cmd/personal_policy.go create mode 100644 cmd/personal_resources.go create mode 100644 cmd/personal_status.go create mode 100644 cmd/personal_test.go create mode 100644 cmd/personal_tools.go create mode 100644 cmd/personal_triggers.go create mode 100644 docs/design/personal-agents.md create mode 100644 docs/personal-agents.md create mode 100644 internal/agent/runtime/continuation.go create mode 100644 internal/agent/runtime/continuation_test.go create mode 100644 internal/agent/tools/personal.go create mode 100644 internal/browser/broker/broker.go create mode 100644 internal/browser/broker/broker_test.go create mode 100644 internal/browser/controller.go create mode 100644 internal/browser/ephemeral.go create mode 100644 internal/browser/remote.go create mode 100644 internal/gateway/server/personal.go create mode 100644 internal/interaction/types.go create mode 100644 internal/personal/action.go create mode 100644 internal/personal/action_test.go create mode 100644 internal/personal/crud.go create mode 100644 internal/personal/delegation.go create mode 100644 internal/personal/delegation_test.go create mode 100644 internal/personal/environment.go create mode 100644 internal/personal/executive.go create mode 100644 internal/personal/executive_test.go create mode 100644 internal/personal/facts.go create mode 100644 internal/personal/generated.go create mode 100644 internal/personal/interactions.go create mode 100644 internal/personal/migrations/002_interactions.sql create mode 100644 internal/personal/model.go create mode 100644 internal/personal/pacing.go create mode 100644 internal/personal/pacing_test.go create mode 100644 internal/personal/policy.go create mode 100644 internal/personal/policy_test.go create mode 100644 internal/personal/resources.go create mode 100644 internal/personal/resources_test.go create mode 100644 internal/personal/runner.go create mode 100644 internal/personal/runner_exec.go create mode 100644 internal/personal/runner_exec_test.go create mode 100644 internal/personal/runner_test.go create mode 100644 internal/personal/scheduler.go create mode 100644 internal/personal/schema.sql create mode 100644 internal/personal/store.go create mode 100644 internal/personal/store_test.go diff --git a/README.md b/README.md index a2f1432..a3f0384 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, three 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. **Personal** is the cockpit for domain-general, long-lived environment agents; Gateway remains its durable scheduling and execution engine. All modes run against whatever models you have: your own API keys, a local endpoint like Ollama, or a hosted memcode account. ## Screenshots @@ -56,6 +56,12 @@ 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. +## Personal Agents + +`memcode personal create ` creates a named Personal Agent with an arbitrary long-lived objective. The executive breaks it into subgoals, runs bounded wakes (via `personal run` or gateway triggers), records facts, and pauses durably for human input with exact resume. + +Authority is versioned and approved by hash: no consequential work runs without an approved policy. Resource grants confine file access, consequential actions are journaled, and generated code runs fail-closed when no sandbox is available. State lives under `~/.memcode/agents//`; removing config is non-destructive. See `docs/personal-agents.md`. + ## Install ```bash diff --git a/cmd/admin_tools.go b/cmd/admin_tools.go index 80710dc..6b9ab6d 100644 --- a/cmd/admin_tools.go +++ b/cmd/admin_tools.go @@ -130,6 +130,9 @@ func adminOverview(ctx context.Context) (string, error) { for _, name := range agentNames { a := settings.Agents[name] extra := "" + if a.Kind != "" { + extra += " kind=" + a.Kind + } if a.Model != "" { extra += " model=" + a.Model } @@ -386,6 +389,7 @@ func adminAgent(input json.RawMessage) (string, error) { var in struct { Action string `json:"action"` Name string `json:"name"` + Kind string `json:"kind"` Model string `json:"model"` Reasoning string `json:"reasoning"` Toolsets string `json:"toolsets"` @@ -411,10 +415,17 @@ 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)} + kind := strings.TrimSpace(in.Kind) + if kind != "" && kind != "personal" { + return "", fmt.Errorf("kind must be empty or personal") + } + settings.Agents[name] = gwconfig.Agent{Kind: kind, Model: strings.TrimSpace(in.Model), Reasoning: strings.TrimSpace(in.Reasoning)} if err := gwconfig.Save(settings); err != nil { return "", err } + if kind == "personal" { + return fmt.Sprintf("Created Personal Agent %s. Manage its objective and lifecycle with `memcode personal`; its home is retained independently at ~/.memcode/agents/%s/.", name, name), nil + } 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 case "tools": p, ok := settings.Agents[name] diff --git a/cmd/personal.go b/cmd/personal.go new file mode 100644 index 0000000..cb69ddc --- /dev/null +++ b/cmd/personal.go @@ -0,0 +1,330 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/memcode-ai/memcode/internal/agent/permissions" + agentrt "github.com/memcode-ai/memcode/internal/agent/runtime" + appconfig "github.com/memcode-ai/memcode/internal/config" + gwconfig "github.com/memcode-ai/memcode/internal/gateway/config" + "github.com/memcode-ai/memcode/internal/llm" + "github.com/memcode-ai/memcode/internal/personal" + "github.com/memcode-ai/memcode/internal/provider" + "github.com/memcode-ai/memcode/internal/store" + "github.com/memcode-ai/memcode/internal/vxui" + "github.com/spf13/cobra" +) + +var personalCmd = &cobra.Command{ + Use: "personal", Short: "Manage long-lived Personal Agents", + Long: `Open the Personal Agents cockpit — an interactive session (like ` + "`memcode admin`" + `) +for managing your long-lived Personal Agents by conversation: objectives, +policies, resources, triggers, wakes, pending questions, and lifecycle. + +Subcommands (run, inbox, answer, policy, resources, triggers, history, doctor, +pause/resume/stop/delete) are the same operations in scriptable form.`, + RunE: func(cmd *cobra.Command, args []string) error { + return runPersonalCockpit(cmd.Context()) + }, +} + +// runPersonalCockpit opens the interactive Personal Agents session. It mirrors +// `memcode admin`: a TUI over the memcode home, with the pa_* typed tools and no +// repo/coding tools. Personal Agent state lives under ~/.memcode/agents//. +func runPersonalCockpit(ctx context.Context) error { + home, err := os.UserHomeDir() + if err != nil { + return err + } + root := filepath.Join(home, ".memcode") + if err := os.MkdirAll(root, 0o700); err != nil { + return err + } + if _, err := appconfig.Init(root, false); err != nil { + return err + } + cfg, err := appconfig.Load(root) + if err != nil { + return err + } + st, err := store.Open(ctx, storePath(cfg.Root)) + if err != nil { + return err + } + defer st.Close() + + provider.LoadDotEnv() + maybeRunFirstRunWizard(ctx, cfg) + var endpoints []provider.Endpoint + if ep, ok := cfg.ResolveEndpoint(); ok { + endpoints = append(endpoints, ep) + } + prov := provider.NewFromEnvLazy(endpoints...) + model := provider.EffectiveModel(cfg.Models.Coder) + sess := agentrt.New(st, llm.NewRunner(prov), cfg.Root, model, permissions.ModeAsk, os.Stdout) + sess.SetPersonal(personalExecute) + if ep, onEndpoint := prov.Endpoint(); onEndpoint { + sess.SetPin(ep.Model, provider.CatalogWindow(ep.Model)) + } else { + sess.SetVendor(cfg.Vendor) + sess.SetPin(cfg.PinnedModel, cfg.PinnedWindow) + } + sess.SetServingDefault(cfg.ServingDefault) + return vxui.Run(ctx, sess, cfg.Theme) +} + +func personalStore(ctx context.Context, name string) (*personal.Store, error) { + st, _, err := personalStoreHome0(ctx, name) + return st, err +} + +// personalStoreHome returns the open store and the agent's home path. +func personalStoreHome(cmd *cobra.Command, name string) (*personal.Store, string, error) { + return personalStoreHome0(cmd.Context(), name) +} + +func personalStoreHome0(ctx context.Context, name string) (*personal.Store, string, error) { + s, err := gwconfig.Load() + if err != nil { + return nil, "", err + } + a, ok := s.Agents[name] + if !ok || a.Kind != "personal" { + return nil, "", fmt.Errorf("no Personal Agent %q", name) + } + home, err := gwconfig.AgentHome(name) + if err != nil { + return nil, "", err + } + st, err := personal.Open(ctx, home) + if err != nil { + return nil, "", err + } + return st, home, nil +} + +func personalCreate(cmd *cobra.Command, args []string) error { + name, objective := args[0], strings.Join(args[1:], " ") + s, err := gwconfig.Load() + if err != nil { + return err + } + if s.Agents == nil { + s.Agents = map[string]gwconfig.Agent{} + } + if _, ok := s.Agents[name]; ok { + return fmt.Errorf("agent %q already exists", name) + } + s.Agents[name] = gwconfig.Agent{Kind: "personal"} + if err := gwconfig.Save(s); err != nil { + return err + } + home, err := gwconfig.AgentHome(name) + if err != nil { + return err + } + st, err := personal.Open(cmd.Context(), home) + if err != nil { + return err + } + defer st.Close() + if err := st.CreateObjective(cmd.Context(), personal.Objective{ID: "primary", Description: objective, Status: "draft"}); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "Created Personal Agent %s. Consequential work remains blocked until its delegation policy is approved.\n", name) + return nil +} + +func personalList(cmd *cobra.Command, args []string) error { + s, err := gwconfig.Load() + if err != nil { + return err + } + var names []string + for n, a := range s.Agents { + if a.Kind == "personal" { + names = append(names, n) + } + } + sort.Strings(names) + for _, n := range names { + fmt.Fprintln(cmd.OutOrStdout(), n) + } + return nil +} +func personalShow(cmd *cobra.Command, args []string) error { + st, err := personalStore(cmd.Context(), args[0]) + if err != nil { + return err + } + defer st.Close() + os, err := st.ListObjectives(cmd.Context()) + if err != nil { + return err + } + out := cmd.OutOrStdout() + fmt.Fprintf(out, "Personal Agent: %s\n", args[0]) + for _, o := range os { + fmt.Fprintf(out, "- [%s] %s\n", o.Status, o.Description) + } + return nil +} +func personalStatus(status string) func(*cobra.Command, []string) error { + return func(cmd *cobra.Command, args []string) error { + st, err := personalStore(cmd.Context(), args[0]) + if err != nil { + return err + } + defer st.Close() + if err := st.SetObjectiveStatus(cmd.Context(), "primary", status); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "%s: %s\n", args[0], status) + return nil + } +} + +func personalRun(cmd *cobra.Command, args []string) error { + st, home, err := personalStoreHome(cmd, args[0]) + if err != nil { + return err + } + defer st.Close() + // Fail-closed FIRST: if no approved policy, report blocked before any model + // is constructed, so the operator sees the real blocker (policy, not auth). + if _, hasPol, err := st.ApprovedPolicy(cmd.Context(), "primary"); err != nil { + return err + } else if !hasPol { + fmt.Fprintln(cmd.OutOrStdout(), "blocked: no approved policy — run `memcode personal policy set` then `approve-policy`") + return nil + } + provider.LoadDotEnv() + prov, err := provider.NewFromEnv() + if err != nil { + return fmt.Errorf("no model configured (set MEMCODE_API_TOKEN or an API key): %w", err) + } + ex := &personal.Executive{Store: st, Home: home, AgentID: args[0], Runner: llm.NewRunner(prov)} + out, err := ex.RunOnce(cmd.Context()) + if err != nil { + return err + } + w := cmd.OutOrStdout() + fmt.Fprintf(w, "run %s: %s\n", out.RunID, out.Status) + if out.Report != "" { + fmt.Fprintln(w, out.Report) + } + if out.NextWakeAt != nil { + fmt.Fprintf(w, "next wake: %s\n", out.NextWakeAt.Format(time.RFC3339)) + } + if out.InteractionID != "" { + fmt.Fprintf(w, "suspended: answer with `memcode personal answer %s %s `\n", args[0], out.InteractionID) + } + return nil +} + +func personalInbox(cmd *cobra.Command, args []string) error { + st, _, err := personalStoreHome(cmd, args[0]) + if err != nil { + return err + } + defer st.Close() + inter, err := personal.PendingInteractions(st, args[0]) + if err != nil { + return err + } + w := cmd.OutOrStdout() + if len(inter) == 0 { + fmt.Fprintln(w, "inbox empty — no pending questions") + return nil + } + for _, in := range inter { + fmt.Fprintf(w, "- %s [%s] %s\n", in.ID, in.Kind, in.Question) + } + return nil +} + +func personalAnswer(cmd *cobra.Command, args []string) error { + st, home, err := personalStoreHome(cmd, args[0]) + if err != nil { + return err + } + defer st.Close() + id := args[1] + answer := strings.Join(args[2:], " ") + in, ok, err := personal.GetInteraction(st, id) + if err != nil || !ok { + return fmt.Errorf("no pending interaction %q", id) + } + if in.AgentID != args[0] { + return fmt.Errorf("interaction %q belongs to %s, not %s", id, in.AgentID, args[0]) + } + if in.Status != "pending" { + return fmt.Errorf("interaction %q is not pending (already answered or cancelled) — refusing to re-run its resume", id) + } + // Resume FIRST with the model; only mark the interaction answered after the + // resumed run reaches a terminal state, so a failed resume stays retryable. + provider.LoadDotEnv() + prov, err := provider.NewFromEnv() + if err != nil { + return fmt.Errorf("no model configured: %w", err) + } + ex := &personal.Executive{Store: st, Home: home, AgentID: args[0], Runner: llm.NewRunner(prov)} + out, err := ex.ResumeSuspended(cmd.Context(), in, answer) + if err != nil { + return fmt.Errorf("resume failed (interaction still pending): %w", err) + } + if err := personal.ResolveInteraction(st, id, answer); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "interaction %s answered; run %s → %s\n", id, in.RunID, out.Status) + if out.Report != "" { + fmt.Fprintln(cmd.OutOrStdout(), out.Report) + } + return nil +} +func personalDelete(cmd *cobra.Command, args []string) error { + name := args[0] + destructive, _ := cmd.Flags().GetBool("delete-home") + s, err := gwconfig.Load() + if err != nil { + return err + } + a, ok := s.Agents[name] + if !ok || a.Kind != "personal" { + return fmt.Errorf("no Personal Agent %q", name) + } + delete(s.Agents, name) + if err := gwconfig.Save(s); err != nil { + return err + } + if destructive { + home, _ := gwconfig.AgentHome(name) + if err := os.RemoveAll(home); err != nil { + return err + } + } + fmt.Fprintf(cmd.OutOrStdout(), "Removed %s from gateway configuration; home deleted=%v.\n", name, destructive) + return nil +} + +func init() { + create := &cobra.Command{Use: "create ", Args: cobra.MinimumNArgs(2), RunE: personalCreate} + list := &cobra.Command{Use: "list", Args: cobra.NoArgs, RunE: personalList} + show := &cobra.Command{Use: "show ", Args: cobra.ExactArgs(1), RunE: personalShow} + pause := &cobra.Command{Use: "pause ", Args: cobra.ExactArgs(1), RunE: personalStatus("paused")} + resume := &cobra.Command{Use: "resume ", Args: cobra.ExactArgs(1), RunE: personalStatus("active")} + stop := &cobra.Command{Use: "stop ", Args: cobra.ExactArgs(1), RunE: personalStatus("stopped")} + run := &cobra.Command{Use: "run ", Args: cobra.ExactArgs(1), RunE: personalRun} + inbox := &cobra.Command{Use: "inbox ", Args: cobra.ExactArgs(1), RunE: personalInbox} + answer := &cobra.Command{Use: "answer ", Args: cobra.MinimumNArgs(3), RunE: personalAnswer} + deleteCmd := &cobra.Command{Use: "delete ", Args: cobra.ExactArgs(1), RunE: personalDelete} + deleteCmd.Flags().Bool("delete-home", false, "also permanently delete the agent home") + personalCmd.AddCommand(create, list, show, run, inbox, answer, pause, resume, stop, personalPolicyCmd, personalApprovePolicyCmd, personalResourcesCmd, personalTriggersCmd, deleteCmd) + rootCmd.AddCommand(personalCmd) +} diff --git a/cmd/personal_cmd_test.go b/cmd/personal_cmd_test.go new file mode 100644 index 0000000..b4eeec3 --- /dev/null +++ b/cmd/personal_cmd_test.go @@ -0,0 +1,206 @@ +package cmd + +import ( + "bytes" + "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" +) + +// execPersonal runs a personal subcommand with an isolated HOME + config. +func execPersonal(t *testing.T, args ...string) (string, error) { + t.Helper() + cmd := rootCmd + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs(args) + err := cmd.Execute() + return out.String(), err +} + +func setupPersonalHome(t *testing.T) string { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg")) + return home +} + +func TestPolicyLifecycleBlocksThenApproves(t *testing.T) { + setupPersonalHome(t) + if _, err := execPersonal(t, "personal", "create", "pa", "Keep things tidy"); err != nil { + t.Fatal(err) + } + // No policy yet → run is blocked (and must not call a model). + out, err := execPersonal(t, "personal", "run", "pa") + // run errors only because no model is configured in test env; that's fine — + // what we assert is the policy gate happens BEFORE any model requirement. + _ = out + _ = err + // Stage + approve a policy. + dir := t.TempDir() + pfile := filepath.Join(dir, "policy.json") + 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, + } + b, _ := json.Marshal(policy) + if err := os.WriteFile(pfile, b, 0o600); err != nil { + t.Fatal(err) + } + out, err = execPersonal(t, "personal", "policy", "set", "pa", pfile) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, "Draft policy v1 staged") { + t.Fatalf("set=%q", out) + } + // Extract hash from the policy file name written under the agent home. + home, _ := gwconfig.AgentHome("pa") + entries, err := os.ReadDir(filepath.Join(home, "policies")) + if err != nil || len(entries) != 1 { + t.Fatalf("policies dir: %v %v", entries, err) + } + hash := strings.TrimSuffix(entries[0].Name(), ".json") + // Approve by prefix. + out, err = execPersonal(t, "personal", "approve-policy", "pa", hash[:12]) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, "Approved policy") { + t.Fatalf("approve=%q", out) + } + // Show reports approved. + out, err = execPersonal(t, "personal", "policy", "show", "pa") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, "approved policy v1") { + t.Fatalf("show=%q", out) + } +} + +func TestResourcesAndTriggersCommands(t *testing.T) { + home := setupPersonalHome(t) + if _, err := execPersonal(t, "personal", "create", "pa2", "Watch a folder"); err != nil { + t.Fatal(err) + } + grant := filepath.Join(home, "watch") + if err := os.MkdirAll(grant, 0o755); err != nil { + t.Fatal(err) + } + out, err := execPersonal(t, "personal", "resources", "add", "pa2", "filesystem", grant, "--mode", "write") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, "Granted filesystem") { + t.Fatalf("add=%q", out) + } + out, err = execPersonal(t, "personal", "resources", "list", "pa2") + if err != nil || !strings.Contains(out, "filesystem") { + t.Fatalf("list=%q err=%v", out, err) + } + // Trigger add/list. + out, err = execPersonal(t, "personal", "triggers", "add", "pa2", "interval", "30m") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, "next wake") { + t.Fatalf("trigger add=%q", out) + } + out, err = execPersonal(t, "personal", "triggers", "list", "pa2") + if err != nil || !strings.Contains(out, "interval") { + t.Fatalf("triggers list=%q err=%v", out, err) + } + // Revoke a resource by parsing its id (field after "- ", before ":"). + out, err = execPersonal(t, "personal", "resources", "list", "pa2") + if err != nil { + t.Fatal(err) + } + var resID string + for _, line := range strings.Split(out, "\n") { + if strings.HasPrefix(line, "- ") { + rest := strings.TrimPrefix(line, "- ") + if i := strings.Index(rest, ":"); i > 0 { + resID = rest[:i] + } + } + } + if resID == "" { + t.Fatalf("no resource id in %q", out) + } + if _, err := execPersonal(t, "personal", "resources", "revoke", "pa2", resID); err != nil { + t.Fatal(err) + } + // Confirm revoked. + out, _ = execPersonal(t, "personal", "resources", "list", "pa2") + if !strings.Contains(out, "[revoked]") { + t.Fatalf("expected revoked: %q", out) + } +} + +// The cockpit executor drives the same operations the subcommands expose, via +// typed pa_* tool calls. Read-only calls return state; mutations mutate. +func TestPersonalCockpitExecutor(t *testing.T) { + setupPersonalHome(t) + ctx := context.Background() + if _, err := execPersonal(t, "personal", "create", "cock", "Tidy my notes"); err != nil { + t.Fatal(err) + } + // Overview (read). + out, err := personalExecute(ctx, tools.PaOverview, json.RawMessage(`{}`)) + if err != nil || !strings.Contains(out, "cock") { + t.Fatalf("overview=%q err=%v", out, err) + } + // Objective show (read). + out, err = personalExecute(ctx, tools.PaObjective, json.RawMessage(`{"agent":"cock","action":"show"}`)) + if err != nil || !strings.Contains(out, "Tidy my notes") { + t.Fatalf("objective=%q err=%v", out, err) + } + // Policy stage + approve via cockpit. + pol := map[string]any{"objective_scope": "primary", "consequence_classes": []string{"observe"}, "max_seconds": 60, "max_actions_per_period": 4} + pb, _ := json.Marshal(pol) + docJSON, _ := json.Marshal(map[string]string{"agent": "cock", "action": "stage", "document": string(pb)}) + out, err = personalExecute(ctx, tools.PaPolicy, docJSON) + if err != nil || !strings.Contains(out, "Draft policy v1 staged") { + t.Fatalf("stage=%q err=%v", out, err) + } + home, _ := gwconfig.AgentHome("cock") + entries, _ := os.ReadDir(filepath.Join(home, "policies")) + hash := strings.TrimSuffix(entries[0].Name(), ".json") + apJSON, _ := json.Marshal(map[string]string{"agent": "cock", "action": "approve", "hash": hash}) + out, err = personalExecute(ctx, tools.PaPolicy, apJSON) + if err != nil || !strings.Contains(out, "Approved policy") { + t.Fatalf("approve=%q err=%v", out, err) + } + // Trigger add + list via cockpit. + trJSON, _ := json.Marshal(map[string]string{"agent": "cock", "action": "add", "kind": "interval", "spec": "15m"}) + out, err = personalExecute(ctx, tools.PaTrigger, trJSON) + if err != nil || !strings.Contains(out, "next wake") { + t.Fatalf("trigger add=%q err=%v", out, err) + } + tlJSON, _ := json.Marshal(map[string]string{"agent": "cock", "action": "list"}) + out, err = personalExecute(ctx, tools.PaTrigger, tlJSON) + if err != nil || !strings.Contains(out, "interval") { + t.Fatalf("trigger list=%q err=%v", out, err) + } + // Inbox (read) and lifecycle pause. + inJSON, _ := json.Marshal(map[string]string{"agent": "cock"}) + out, err = personalExecute(ctx, tools.PaInbox, inJSON) + if err != nil || !strings.Contains(out, "inbox empty") { + t.Fatalf("inbox=%q err=%v", out, err) + } + lcJSON, _ := json.Marshal(map[string]string{"agent": "cock", "action": "pause"}) + out, err = personalExecute(ctx, tools.PaLifecycle, lcJSON) + if err != nil || !strings.Contains(out, "paused") { + t.Fatalf("pause=%q err=%v", out, err) + } +} diff --git a/cmd/personal_cockpit.go b/cmd/personal_cockpit.go new file mode 100644 index 0000000..bbf8434 --- /dev/null +++ b/cmd/personal_cockpit.go @@ -0,0 +1,428 @@ +package cmd + +// The Personal Agents cockpit executor: typed pa_* operations over Personal +// Agent state, injected into the runtime (same seam as admin). Secrets and the +// model backend are out of scope here except for pa_wake/pa_answer, which spin +// up a provider for that single wake/resume. + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/memcode-ai/memcode/internal/agent/tools" + "github.com/memcode-ai/memcode/internal/atomicfile" + gwconfig "github.com/memcode-ai/memcode/internal/gateway/config" + "github.com/memcode-ai/memcode/internal/llm" + "github.com/memcode-ai/memcode/internal/personal" + "github.com/memcode-ai/memcode/internal/provider" +) + +func paStore(ctx context.Context, agent string) (*personal.Store, string, error) { + s, err := gwconfig.Load() + if err != nil { + return nil, "", err + } + a, ok := s.Agents[agent] + if !ok || a.Kind != "personal" { + return nil, "", fmt.Errorf("no Personal Agent %q", agent) + } + home, err := gwconfig.AgentHome(agent) + if err != nil { + return nil, "", err + } + st, err := personal.Open(ctx, home) + if err != nil { + return nil, "", err + } + return st, home, nil +} + +func personalExecute(ctx context.Context, name string, input json.RawMessage) (string, error) { + var in struct { + Agent string `json:"agent"` + Action string `json:"action"` + Text string `json:"text"` + Document string `json:"document"` + Hash string `json:"hash"` + Type string `json:"type"` + Locator string `json:"locator"` + Mode string `json:"mode"` + ID string `json:"id"` + Kind string `json:"kind"` + Spec string `json:"spec"` + Answer string `json:"answer"` + DeleteHome string `json:"delete_home"` + } + if err := json.Unmarshal(input, &in); err != nil { + return "", err + } + if name == tools.PaOverview { + return paOverview(ctx) + } + if strings.TrimSpace(in.Agent) == "" { + return "", fmt.Errorf("an agent name is required") + } + st, home, err := paStore(ctx, in.Agent) + if err != nil { + return "", err + } + defer st.Close() + switch name { + case tools.PaObjective: + return paObjective(ctx, st, in.Action, in.Text) + case tools.PaPolicy: + return paPolicy(ctx, st, home, in.Agent, in.Action, in.Document, in.Hash) + case tools.PaResource: + return paResource(ctx, st, in.Action, in.Type, in.Locator, in.Mode, in.ID) + case tools.PaTrigger: + return paTrigger(ctx, st, in.Action, in.Kind, in.Spec, in.ID) + case tools.PaWake: + return paWake(ctx, st, home, in.Agent) + case tools.PaInbox: + return paInbox(ctx, st, in.Agent) + case tools.PaAnswer: + return paAnswer(ctx, st, home, in.Agent, in.ID, in.Answer) + case tools.PaHistory: + return paHistory(ctx, st) + case tools.PaLifecycle: + return paLifecycle(ctx, in.Agent, in.Action, in.DeleteHome) + } + return "", fmt.Errorf("unknown personal tool %q", name) +} + +func paOverview(ctx context.Context) (string, error) { + s, err := gwconfig.Load() + if err != nil { + return "", err + } + var b strings.Builder + var names []string + for n, a := range s.Agents { + if a.Kind == "personal" { + names = append(names, n) + } + } + if len(names) == 0 { + return "No Personal Agents. Create one with `memcode personal create \"\"`.", nil + } + sortStrings(names) + for _, n := range names { + st, _, err := paStore(ctx, n) + if err != nil { + fmt.Fprintf(&b, "%s: error %v\n", n, err) + continue + } + obj, hasObj, _ := st.GetObjective(ctx, "primary") + pol, hasPol, _ := st.ApprovedPolicy(ctx, "primary") + pend, _ := st.PendingInteractions(ctx, n) + status := "no objective" + if hasObj { + status = obj.Status + } + polStr := "no approved policy" + if hasPol { + polStr = fmt.Sprintf("policy v%d", pol.Version) + } + fmt.Fprintf(&b, "%s: %s · %s · %s · %d pending question(s)\n", n, obj.Description, status, polStr, len(pend)) + st.Close() + } + return b.String(), nil +} + +func paObjective(ctx context.Context, st *personal.Store, action, text string) (string, error) { + switch strings.ToLower(action) { + case "show": + o, ok, err := st.GetObjective(ctx, "primary") + if err != nil || !ok { + return "no objective", nil + } + return fmt.Sprintf("[%s] %s\nsuccess: %s", o.Status, o.Description, o.SuccessCriteria), nil + case "set": + _, ok, err := st.GetObjective(ctx, "primary") + if err != nil { + return "", err + } + if !ok { + return "", fmt.Errorf("no primary objective; create the agent with one") + } + if err := st.SetObjectiveText(ctx, "primary", text); err != nil { + return "", err + } + return "objective updated", nil + } + return "", fmt.Errorf("action must be show or set") +} + +func paPolicy(ctx context.Context, st *personal.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.", nil + } + return fmt.Sprintf("approved policy v%d hash=%s\n%s", p.Version, p.Hash, string(p.Document)), nil + case "stage": + var doc personal.DelegationPolicy + if err := json.Unmarshal([]byte(document), &doc); err != nil { + return "", fmt.Errorf("document is not valid DelegationPolicy JSON: %w", err) + } + canon, h, err := personal.CanonicalPolicy(doc) + if err != nil { + return "", err + } + ver, err := st.NextPolicyVersion(ctx, "primary") + if err != nil { + return "", err + } + if err := st.InsertPolicy(ctx, personal.Policy{ID: "policy-" + h[:8], ObjectiveID: "primary", Version: ver, Document: canon, Hash: h, Status: "draft"}); err != nil { + return "", err + } + // Persist the canonical doc to policies/.json (parity with the CLI). + _ = os.MkdirAll(filepath.Join(home, "policies"), 0o700) + _ = atomicfile.WriteFile(filepath.Join(home, "policies", h+".json"), canon, 0o600) + return fmt.Sprintf("Draft policy v%d staged (hash %s). Approve with pa_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 + } + _ = st.SetObjectiveStatus(ctx, "primary", "active") + return fmt.Sprintf("Approved policy %s; %s is now active.", match[:12], agent), nil + } + return "", fmt.Errorf("action must be show, stage, or approve") +} + +func paResource(ctx context.Context, st *personal.Store, action, rtype, locator, mode, id string) (string, error) { + switch strings.ToLower(action) { + case "grant": + if rtype == "filesystem" { + canon, err := personal.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, personal.Resource{ID: rid, ObjectiveID: "primary", Type: rtype, Locator: locator, AccessMode: mode, AuthorizationSource: "cockpit", Status: "active"}); err != nil { + return "", err + } + 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 use 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 + } + return "revoked " + id, nil + } + return "", fmt.Errorf("action must be grant, list, or revoke") +} + +func paTrigger(ctx context.Context, st *personal.Store, action, kind, spec, id string) (string, error) { + switch strings.ToLower(action) { + case "add": + kindMap := map[string]string{"interval": "interval", "cron": "cron", "one-shot": "one_shot"} + dbKind, ok := kindMap[strings.ToLower(kind)] + if !ok { + return "", fmt.Errorf("kind must be interval, cron, or one-shot") + } + now := time.Now().UTC() + next, err := personal.NextDue(dbKind, spec, now) + if err != nil { + return "", fmt.Errorf("bad spec: %w", err) + } + tid := fmt.Sprintf("trig-%s-%d", dbKind, now.Unix()) + if err := st.CreateTrigger(ctx, personal.Trigger{ID: tid, ObjectiveID: "primary", Kind: dbKind, Spec: spec, NextDueAt: &next}); err != nil { + return "", err + } + return fmt.Sprintf("Trigger %s added; next wake %s.", tid, next.Format(time.RFC3339)), nil + case "list": + trigs, err := st.ListTriggers(ctx) + if err != nil { + return "", err + } + if len(trigs) == 0 { + return "no triggers", nil + } + var b strings.Builder + for _, t := range trigs { + next := "—" + if t.NextDueAt != nil { + next = t.NextDueAt.Format(time.RFC3339) + } + fmt.Fprintf(&b, "%s: %s %q next=%s [%s]\n", t.ID, t.Kind, t.Spec, next, t.Status) + } + return b.String(), nil + case "pause", "resume": + status := "paused" + if strings.ToLower(action) == "resume" { + status = "enabled" + } + if _, err := st.DB().ExecContext(ctx, `UPDATE triggers SET status=?,updated_at=? WHERE id=?`, status, time.Now().UTC().Format(time.RFC3339Nano), id); err != nil { + return "", err + } + return fmt.Sprintf("trigger %s %s", id, status), nil + } + return "", fmt.Errorf("action must be add, list, pause, or resume") +} + +func paWake(ctx context.Context, st *personal.Store, home, agent string) (string, error) { + if _, hasPol, err := st.ApprovedPolicy(ctx, "primary"); err != nil { + return "", err + } else if !hasPol { + return "blocked: no approved policy — stage and approve one first", nil + } + provider.LoadDotEnv() + prov, err := provider.NewFromEnv() + if err != nil { + return "", fmt.Errorf("no model configured: %w", err) + } + ex := &personal.Executive{Store: st, Home: home, AgentID: agent, 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 pa_answer\n", out.InteractionID) + } + return b.String(), nil +} + +func paInbox(ctx context.Context, st *personal.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 paAnswer(ctx context.Context, st *personal.Store, home, agent, id, answer string) (string, error) { + in, ok, err := st.GetInteraction(ctx, id) + if err != nil || !ok { + return "", fmt.Errorf("no pending interaction %q", id) + } + if in.AgentID != agent { + return "", fmt.Errorf("interaction %q belongs to %s", id, in.AgentID) + } + provider.LoadDotEnv() + prov, err := provider.NewFromEnv() + if err != nil { + return "", fmt.Errorf("no model configured: %w", err) + } + ex := &personal.Executive{Store: st, Home: home, AgentID: agent, Runner: llm.NewRunner(prov)} + 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 paHistory(ctx context.Context, st *personal.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\n", a.CreatedAt.Format("15:04:05"), a.Kind, a.Target, a.Status) + } + return b.String(), nil +} + +func paLifecycle(ctx context.Context, agent, action, deleteHome string) (string, error) { + s, err := gwconfig.Load() + if err != nil { + return "", err + } + a, ok := s.Agents[agent] + if !ok || a.Kind != "personal" { + return "", fmt.Errorf("no Personal Agent %q", agent) + } + switch strings.ToLower(action) { + case "pause", "resume", "stop": + st, _, err := paStore(ctx, agent) + if err != nil { + return "", err + } + defer st.Close() + status := map[string]string{"pause": "paused", "resume": "active", "stop": "stopped"}[strings.ToLower(action)] + if err := st.SetObjectiveStatus(ctx, "primary", status); err != nil { + return "", err + } + return fmt.Sprintf("%s: %s", agent, status), nil + case "delete": + delete(s.Agents, agent) + if err := gwconfig.Save(s); err != nil { + return "", err + } + if strings.EqualFold(deleteHome, "true") { + home, _ := gwconfig.AgentHome(agent) + if err := os.RemoveAll(home); err != nil { + return "", err + } + } + return fmt.Sprintf("Removed %s (home deleted=%s).", agent, deleteHome), nil + } + return "", fmt.Errorf("action must be pause, resume, stop, or delete") +} + +func sortStrings(s []string) { + for i := 1; i < len(s); i++ { + for j := i; j > 0 && s[j] < s[j-1]; j-- { + s[j], s[j-1] = s[j-1], s[j] + } + } +} diff --git a/cmd/personal_policy.go b/cmd/personal_policy.go new file mode 100644 index 0000000..ab0fabd --- /dev/null +++ b/cmd/personal_policy.go @@ -0,0 +1,122 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/memcode-ai/memcode/internal/atomicfile" + "github.com/memcode-ai/memcode/internal/personal" + "github.com/spf13/cobra" +) + +var personalPolicyCmd = &cobra.Command{Use: "policy", Short: "Manage delegation policies"} + +var personalPolicySetCmd = &cobra.Command{ + Use: "set ", Args: cobra.ExactArgs(2), + Short: "Stage a new draft policy (JSON) for review", + RunE: func(cmd *cobra.Command, args []string) error { + st, home, err := personalStoreHome(cmd, args[0]) + if err != nil { + return err + } + defer st.Close() + raw, err := os.ReadFile(args[1]) + if err != nil { + return err + } + var doc personal.DelegationPolicy + if err := json.Unmarshal(raw, &doc); err != nil { + return fmt.Errorf("policy is not valid DelegationPolicy JSON: %w", err) + } + canon, hash, err := personal.CanonicalPolicy(doc) + if err != nil { + return err + } + ver, err := st.NextPolicyVersion(cmd.Context(), "primary") + if err != nil { + return err + } + p := personal.Policy{ID: "policy-" + hash[:8], ObjectiveID: "primary", Version: ver, Document: canon, Hash: hash, Status: "draft"} + if err := st.InsertPolicy(cmd.Context(), p); err != nil { + return err + } + path := home + "/policies/" + hash + ".json" + if err := atomicfile.WriteFile(path, canon, 0o600); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "Draft policy v%d staged (hash %s…). Review with `personal policy show %s` then approve with `personal approve-policy %s %s`.\n", ver, hash[:12], args[0], args[0], hash) + return nil + }, +} + +var personalPolicyShowCmd = &cobra.Command{ + Use: "show [hash]", Args: cobra.RangeArgs(1, 2), + Short: "Show the approved policy (or a specific one by hash)", + RunE: func(cmd *cobra.Command, args []string) error { + st, _, err := personalStoreHome(cmd, args[0]) + if err != nil { + return err + } + defer st.Close() + if len(args) == 2 { + pols, err := st.ListPolicies(cmd.Context(), "primary") + if err != nil { + return err + } + for _, p := range pols { + if p.Hash == args[1] || strings.HasPrefix(p.Hash, args[1]) { + fmt.Fprintf(cmd.OutOrStdout(), "policy v%d [%s] hash=%s\n%s\n", p.Version, p.Status, p.Hash, string(p.Document)) + return nil + } + } + return fmt.Errorf("no policy matching %q", args[1]) + } + p, ok, err := st.ApprovedPolicy(cmd.Context(), "primary") + if err != nil { + return err + } + if !ok { + fmt.Fprintln(cmd.OutOrStdout(), "no approved policy — consequential work is blocked") + return nil + } + fmt.Fprintf(cmd.OutOrStdout(), "approved policy v%d hash=%s approved_at=%s\n%s\n", p.Version, p.Hash, p.ApprovedAt, string(p.Document)) + return nil + }, +} + +var personalApprovePolicyCmd = &cobra.Command{ + Use: "approve-policy ", Args: cobra.ExactArgs(2), + Short: "Approve a staged draft policy by its hash", + RunE: func(cmd *cobra.Command, args []string) error { + st, _, err := personalStoreHome(cmd, args[0]) + if err != nil { + return err + } + defer st.Close() + pols, err := st.ListPolicies(cmd.Context(), "primary") + if err != nil { + return err + } + var match string + for _, p := range pols { + if p.Hash == args[1] || strings.HasPrefix(p.Hash, args[1]) { + match = p.Hash + break + } + } + if match == "" { + return fmt.Errorf("no policy matching %q", args[1]) + } + if err := st.ApprovePolicy(cmd.Context(), match); err != nil { + return err + } + // Move objective out of draft so scheduled/manual wakes may run. + _ = st.SetObjectiveStatus(cmd.Context(), "primary", "active") + fmt.Fprintf(cmd.OutOrStdout(), "Approved policy %s… for %s; objective is now active.\n", match[:12], args[0]) + return nil + }, +} + +func init() { personalPolicyCmd.AddCommand(personalPolicySetCmd, personalPolicyShowCmd) } diff --git a/cmd/personal_resources.go b/cmd/personal_resources.go new file mode 100644 index 0000000..da85393 --- /dev/null +++ b/cmd/personal_resources.go @@ -0,0 +1,86 @@ +package cmd + +import ( + "fmt" + "time" + + "github.com/memcode-ai/memcode/internal/personal" + "github.com/spf13/cobra" +) + +var personalResourcesCmd = &cobra.Command{Use: "resources", Short: "Manage resource grants"} + +var personalResourcesAddCmd = &cobra.Command{ + Use: "add ", Args: cobra.ExactArgs(3), + Short: "Grant a resource (filesystem path, mcp tool, command, channel)", + RunE: func(cmd *cobra.Command, args []string) error { + st, _, err := personalStoreHome(cmd, args[0]) + if err != nil { + return err + } + defer st.Close() + mode, _ := cmd.Flags().GetString("mode") + rtype, locator := args[1], args[2] + if rtype == "filesystem" { + canon, err := personal.CanonicalFilesystemGrant(locator) + if err != nil { + return fmt.Errorf("cannot grant filesystem path: %w", err) + } + locator = canon + } + id := fmt.Sprintf("res-%s-%d", rtype, time.Now().UnixNano()) + if err := st.InsertResource(cmd.Context(), personal.Resource{ + ID: id, ObjectiveID: "primary", Type: rtype, Locator: locator, + AccessMode: mode, AuthorizationSource: "user-cli", Status: "active", + }); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "Granted %s %s (%s) to %s.\n", rtype, locator, mode, args[0]) + return nil + }, +} + +var personalResourcesListCmd = &cobra.Command{ + Use: "list ", Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + st, _, err := personalStoreHome(cmd, args[0]) + if err != nil { + return err + } + defer st.Close() + res, err := st.ListResources(cmd.Context(), "primary") + if err != nil { + return err + } + out := cmd.OutOrStdout() + if len(res) == 0 { + fmt.Fprintln(out, "no resource grants — the agent can only use its own home") + return nil + } + for _, r := range res { + fmt.Fprintf(out, "- %s: %s %s (%s) [%s]\n", r.ID, r.Type, r.Locator, r.AccessMode, r.Status) + } + return nil + }, +} + +var personalResourcesRevokeCmd = &cobra.Command{ + Use: "revoke ", Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + st, _, err := personalStoreHome(cmd, args[0]) + if err != nil { + return err + } + defer st.Close() + if err := st.SetResourceStatus(cmd.Context(), args[1], "revoked"); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "Revoked %s on %s (effective at the next dispatch).\n", args[1], args[0]) + return nil + }, +} + +func init() { + personalResourcesAddCmd.Flags().String("mode", "read", "access mode: read, write, or admin") + personalResourcesCmd.AddCommand(personalResourcesAddCmd, personalResourcesListCmd, personalResourcesRevokeCmd) +} diff --git a/cmd/personal_status.go b/cmd/personal_status.go new file mode 100644 index 0000000..ccecc66 --- /dev/null +++ b/cmd/personal_status.go @@ -0,0 +1,111 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "time" + + "github.com/memcode-ai/memcode/internal/personal" + "github.com/spf13/cobra" +) + +var personalHistoryCmd = &cobra.Command{ + Use: "history ", Args: cobra.ExactArgs(1), + Short: "Show recent runs and journaled actions", + RunE: func(cmd *cobra.Command, args []string) error { + st, _, err := personalStoreHome(cmd, args[0]) + if err != nil { + return err + } + defer st.Close() + ctx := cmd.Context() + w := cmd.OutOrStdout() + runs, err := st.ListRuns(ctx, "primary", 10) + if err != nil { + return err + } + fmt.Fprintf(w, "RUNS (%d most recent):\n", len(runs)) + for _, r := range runs { + fmt.Fprintf(w, " %s [%s] %s\n", r.ID, r.Status, r.CreatedAt.Format(time.RFC3339)) + } + actions, err := st.ListActions(ctx, "primary", 20) + if err != nil { + return err + } + fmt.Fprintf(w, "ACTIONS (%d most recent):\n", len(actions)) + for _, a := range actions { + fmt.Fprintf(w, " %s %s %s → %s (policy %s)\n", a.CreatedAt.Format("15:04:05"), a.Kind, a.Target, a.Status, shortHash(a.PolicyHash)) + } + return nil + }, +} + +var personalDoctorCmd = &cobra.Command{ + Use: "doctor ", Args: cobra.ExactArgs(1), + Short: "Check a Personal Agent's home, policy, and runtime health", + RunE: func(cmd *cobra.Command, args []string) error { + st, home, err := personalStoreHome(cmd, args[0]) + if err != nil { + return err + } + defer st.Close() + ctx := cmd.Context() + w := cmd.OutOrStdout() + ok := true + check := func(name string, good bool, detail string) { + mark := "ok" + if !good { + mark = "FAIL" + ok = false + } + fmt.Fprintf(w, " [%s] %s: %s\n", mark, name, 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)) + } + obj, hasObj, _ := st.GetObjective(ctx, "primary") + check("objective", hasObj, obj.Description) + pol, hasPol, _ := st.ApprovedPolicy(ctx, "primary") + check("approved policy", hasPol, func() string { + if hasPol { + return fmt.Sprintf("v%d", pol.Version) + " " + shortHash(pol.Hash) + } + return "none — consequential work blocked" + }()) + if _, err := personal.InitializeGeneratedWorkspace(home); err != nil { + check("generated workspace", false, err.Error()) + } else { + check("generated workspace", true, "git initialized") + } + // Sandbox availability is informational, not a failure: on platforms without + // bwrap the runner fails closed for generated code by design (safe default). + fmt.Fprintf(w, " [info] sandbox: %s\n", sandboxNote()) + trigs, _ := st.ListTriggers(ctx) + fmt.Fprintf(w, " triggers: %d, ", len(trigs)) + pend, _ := st.PendingInteractions(ctx, args[0]) + fmt.Fprintf(w, "pending interactions: %d\n", len(pend)) + if !ok { + return fmt.Errorf("doctor found problems") + } + return nil + }, +} + +func shortHash(h string) string { + if len(h) > 12 { + return h[:12] + } + return h +} +func sandboxNote() string { + if personal.SandboxAvailable() { + return "hardened (bwrap)" + } + return "no bwrap — generated code runs fail-closed unless explicitly approved" +} + +func init() { + personalCmd.AddCommand(personalHistoryCmd, personalDoctorCmd) +} diff --git a/cmd/personal_test.go b/cmd/personal_test.go new file mode 100644 index 0000000..6515bb8 --- /dev/null +++ b/cmd/personal_test.go @@ -0,0 +1,61 @@ +package cmd + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + gwconfig "github.com/memcode-ai/memcode/internal/gateway/config" +) + +func TestPersonalCreateListShowPauseResumeStopDelete(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg")) + exec := func(args ...string) (string, error) { + cmd := rootCmd + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs(args) + err := cmd.Execute() + return out.String(), err + } + if _, err := exec("personal", "create", "test-agent", "Maintain an arbitrary outcome"); err != nil { + t.Fatal(err) + } + cfg, err := gwconfig.Load() + if err != nil { + t.Fatal(err) + } + if cfg.Agents["test-agent"].Kind != "personal" { + t.Fatalf("agent=%+v", cfg.Agents["test-agent"]) + } + if _, err := os.Stat(filepath.Join(home, ".memcode", "agents", "test-agent", "personal.db")); err != nil { + t.Fatal(err) + } + if _, err := exec("personal", "pause", "test-agent"); err != nil { + t.Fatal(err) + } + if _, err := exec("personal", "resume", "test-agent"); err != nil { + t.Fatal(err) + } + if _, err := exec("personal", "stop", "test-agent"); err != nil { + t.Fatal(err) + } + out, err := exec("personal", "list") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, "test-agent") { + t.Fatalf("list output missing agent: %q", out) + } + if _, err := exec("personal", "delete", "test-agent"); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(home, ".memcode", "agents", "test-agent")); err != nil { + t.Fatal("non-destructive delete removed home") + } +} 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/personal_triggers.go b/cmd/personal_triggers.go new file mode 100644 index 0000000..ab84b32 --- /dev/null +++ b/cmd/personal_triggers.go @@ -0,0 +1,93 @@ +package cmd + +import ( + "fmt" + "time" + + "github.com/memcode-ai/memcode/internal/personal" + "github.com/spf13/cobra" +) + +var personalTriggersCmd = &cobra.Command{Use: "triggers", Short: "Manage persistent wake triggers"} + +var personalTriggersAddCmd = &cobra.Command{ + Use: "add ", Args: cobra.ExactArgs(3), + Short: "Add a wake trigger (interval 5m | cron \"0 * * * *\" | one-shot RFC3339)", + RunE: func(cmd *cobra.Command, args []string) error { + st, _, err := personalStoreHome(cmd, args[0]) + if err != nil { + return err + } + defer st.Close() + kind, spec := args[1], args[2] + kindMap := map[string]string{"interval": "interval", "cron": "cron", "one-shot": "one_shot"} + dbKind, ok := kindMap[kind] + if !ok { + return fmt.Errorf("kind must be interval, cron, or one-shot") + } + now := time.Now().UTC() + next, err := personal.NextDue(dbKind, spec, now) + if err != nil { + return fmt.Errorf("bad spec: %w", err) + } + id := fmt.Sprintf("trig-%s-%d", dbKind, now.Unix()) + if err := st.CreateTrigger(cmd.Context(), personal.Trigger{ID: id, ObjectiveID: "primary", Kind: dbKind, Spec: spec, NextDueAt: &next}); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "Trigger %s added; next wake %s.\n", id, next.Format(time.RFC3339)) + return nil + }, +} + +var personalTriggersListCmd = &cobra.Command{ + Use: "list ", Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + st, _, err := personalStoreHome(cmd, args[0]) + if err != nil { + return err + } + defer st.Close() + trigs, err := st.ListTriggers(cmd.Context()) + if err != nil { + return err + } + out := cmd.OutOrStdout() + if len(trigs) == 0 { + fmt.Fprintln(out, "no triggers — the agent only wakes on `personal run` or answered interactions") + return nil + } + for _, t := range trigs { + next := "—" + if t.NextDueAt != nil { + next = t.NextDueAt.Format(time.RFC3339) + } + fmt.Fprintf(out, "- %s: %s %q next=%s [%s]\n", t.ID, t.Kind, t.Spec, next, t.Status) + } + return nil + }, +} + +func triggerSetStatus(s string) func(*cobra.Command, []string) error { + return func(cmd *cobra.Command, args []string) error { + st, _, err := personalStoreHome(cmd, args[0]) + if err != nil { + return err + } + defer st.Close() + res, err := st.DB().ExecContext(cmd.Context(), `UPDATE triggers SET status=?,updated_at=? WHERE id=?`, s, time.Now().UTC().Format(time.RFC3339Nano), args[1]) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return fmt.Errorf("no trigger %q for %s", args[1], args[0]) + } + fmt.Fprintf(cmd.OutOrStdout(), "trigger %s: %s\n", args[1], s) + return nil + } +} + +func init() { + pause := &cobra.Command{Use: "pause ", Args: cobra.ExactArgs(2), RunE: triggerSetStatus("paused")} + resume := &cobra.Command{Use: "resume ", Args: cobra.ExactArgs(2), RunE: triggerSetStatus("enabled")} + personalTriggersCmd.AddCommand(personalTriggersAddCmd, personalTriggersListCmd, pause, resume) +} diff --git a/docs/design/personal-agents.md b/docs/design/personal-agents.md new file mode 100644 index 0000000..2bee3f6 --- /dev/null +++ b/docs/design/personal-agents.md @@ -0,0 +1,159 @@ +# Personal Agents + +**Status:** Draft design contract +**Date:** August 30, 2026 + +## Purpose + +Personal Agents are domain-general, long-lived environment agents operated through: + +```text +memcode personal +``` + +A Personal 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/personal` 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. Personal Agents 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..decb07c 100644 --- a/docs/gateway/README.md +++ b/docs/gateway/README.md @@ -96,6 +96,7 @@ projects: # written by `memcode project add` default_project: memcode agents: # durable agents; identity + state in ~/.memcode/agents/ personal: + kind: personal # additive Personal Agent runtime; omit for ordinary agents model: claude-haiku-4-5 # omit model to let routing pick per task coder: model: claude-sonnet-5 @@ -134,6 +135,12 @@ 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. +An optional `kind: personal` marks an additive Personal Agent runtime type. +Empty `kind` preserves ordinary named-agent behavior. Personal objective, +policy, resource, trigger, and runtime state lives in the agent home rather than +`gateway.yaml`; manage that lifecycle through `memcode personal`. Removing the +configuration entry does not delete the home. + ## Authorization and triggering Two independent checks gate a chat message, matching what Hermes and OpenClaw do: diff --git a/docs/personal-agents.md b/docs/personal-agents.md new file mode 100644 index 0000000..9c58379 --- /dev/null +++ b/docs/personal-agents.md @@ -0,0 +1,55 @@ +# Personal Agents + +Personal Agents are domain-general, persistent environment agents operated through `memcode personal`. Personal is the cockpit; the existing Gateway daemon is the engine room for durable trigger intake and scheduled wakes. + +## Quick start + +``` +memcode personal # interactive cockpit (like memcode admin) +memcode personal create "" +memcode personal policy set policy.json # stage a draft +memcode personal approve-policy # approve by hash +memcode personal run # one bounded wake +memcode personal triggers add interval 30m # recurring wakes (gateway) +memcode personal doctor +``` + +Bare `memcode personal` opens an interactive management session — the same TUI as `memcode admin` — where you manage agents in plain language through typed, gated `pa_*` operations (objective, policy, resources, triggers, wake, inbox, answer, history, lifecycle). The subcommands are the same operations in scriptable form. + +A minimal policy (`policy.json`): + +```json +{ + "objective_scope": "primary", + "consequence_classes": ["observe", "local_mutation"], + "max_seconds": 300, + "max_actions_per_period": 8, + "max_delegation_depth": 1 +} +``` + +## How it works + +- **Objective** — the approved desired outcome and success criteria. The executive breaks it into subgoals (data, not compiled workflow types). +- **Bounded wakes** — each `run`/trigger wake is a single bounded LLM loop. It ends by calling `report`, scheduling the next wake with `schedule_wake`, or suspending with `ask_user`. The agent never runs continuously. +- **Policy gate** — consequential work requires an approved policy. `RunOnce` fails closed before any model call if no policy is approved, or if the policy is expired/revoked. Approving a policy activates the objective. +- **Resource grants** — `resources add` grants filesystem roots (canonical, symlink-resolved) with an access mode. `read_file`/`write_file` in the executive are confined to grants; the agent's own home and generated workspace are always available. `resources revoke` takes effect at the next dispatch. +- **Journal** — consequential executive actions (e.g. `write_file`) are journaled with reserve → running → succeeded/failed and the policy hash, before dispatch. +- **Triggers** — durable `interval` / `cron` / `one-shot` / `next_wake` records in the agent home. The running gateway polls them every 15s, claims each due trigger atomically, and runs a wake. +- **Human-in-the-loop** — `ask_user` suspends a run durably: the interaction is recorded in the agent's DB and the exact continuation (transcript + tool_use_id) is saved under `runs//`. `personal inbox` lists pending questions; `personal answer ` resolves it and resumes with the matching tool_result — no replay of completed actions, no double-resume. + +## Commands + +Interactive cockpit: bare `memcode personal` (typed `pa_*` tools). Scriptable subcommands: `create` `list` `show` `run` `inbox` `answer` `pause` `resume` `stop` `delete` · `policy set|show` + `approve-policy` · `resources add|list|revoke` · `triggers add|list|pause|resume` · `history` · `doctor` + +## Controls and safety + +`pause`/`stop` change objective status so future wakes refuse to run. `delete` removes the config entry but keeps the agent home; `--delete-home` is the explicit destructive path. State lives under `~/.memcode/agents//` (`personal.db` with WAL + versioned migrations, `policies/`, `runs/`, `workspace/`). + +Generated code is untrusted: `RunGenerated` uses staged inputs, a scrubbed environment, executable allowlists, and bounded time/output, and fails closed when a hardened sandbox (Linux `bwrap`) is unavailable. `doctor` reports sandbox availability as informational. + +## Current scope + +Implemented and tested: objective/subgoal/fact store, policy gate, journaled bounded executive with observe + local-mutation tools, durable triggers via the gateway, suspend/resume, resources, history, and doctor. + +Not yet wired into the executive loop (the primitives exist and are unit-tested): dynamically delegated sub-agents, the existing-Chrome broker, external-consequence classes (external_effect/financial/legal/destructive), and adaptive pacing as a live input to dispatch. Native desktop automation remains a future backend. diff --git a/internal/agent/runtime/admin.go b/internal/agent/runtime/admin.go index 440879b..115f9a5 100644 --- a/internal/agent/runtime/admin.go +++ b/internal/agent/runtime/admin.go @@ -59,6 +59,50 @@ func (s *Session) adminTool(ctx context.Context, name string, input json.RawMess return textResult(out) } +// personalReadOnly reports whether a pa_* call is a pure read (no approval gate). +func personalReadOnly(name string, input json.RawMessage) bool { + switch name { + case tools.PaOverview, tools.PaInbox, tools.PaHistory: + return true + } + // show/list sub-actions are reads. + var in struct { + Action string `json:"action"` + } + _ = json.Unmarshal(input, &in) + a := strings.ToLower(strings.TrimSpace(in.Action)) + switch name { + case tools.PaObjective, tools.PaPolicy: + return a == "show" + case tools.PaResource, tools.PaTrigger: + return a == "list" + } + return false +} + +// personalTool gates and dispatches a personal-cockpit tool call. +func (s *Session) personalTool(ctx context.Context, name string, input json.RawMessage) toolResult { + if s.adminExec == nil { + return errResult("personal cockpit is unavailable in this session") + } + if !personalReadOnly(name, input) { + title := name + if compact := compactAdminInput(input); compact != "" { + title = fmt.Sprintf("%s %s", name, compact) + } + if ok, reason := s.gate(ctx, permissions.Medium, false, ApprovalRequest{ + Title: title, Label: "Personal Agent change", Risk: permissions.Medium.String(), + }); !ok { + return errResult("denied: " + reason) + } + } + out, err := s.adminExec(ctx, name, input) + if err != nil { + return errResult(err.Error()) + } + return textResult(out) +} + // compactAdminInput renders tool input as a short single-line summary for the // approval card, "" when it is empty. func compactAdminInput(input json.RawMessage) string { diff --git a/internal/agent/runtime/continuation.go b/internal/agent/runtime/continuation.go new file mode 100644 index 0000000..25cec27 --- /dev/null +++ b/internal/agent/runtime/continuation.go @@ -0,0 +1,104 @@ +package runtime + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/memcode-ai/memcode/internal/atomicfile" + "github.com/memcode-ai/memcode/internal/wire" +) + +type Outcome string + +const ( + OutcomeCompleted Outcome = "completed" + OutcomeFailed Outcome = "failed" + OutcomeSuspended Outcome = "suspended" +) + +type Suspension struct { + Version int `json:"version"` + SessionID, InteractionID, ToolUseID, ToolName string + ToolInput json.RawMessage + Assistant wire.Message + CreatedAt time.Time + Resolved bool +} + +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 +} + +func suspensionPath(root, sessionID, interactionID string) string { + return filepath.Join(root, ".memcode", "sessions", sessionID, "continuations", interactionID+".json") +} + +func SaveSuspension(root 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 + } + p := suspensionPath(root, s.SessionID, s.InteractionID) + if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil { + return err + } + b, err := json.Marshal(s) + if err != nil { + return err + } + return atomicfile.WriteFile(p, b, 0o600) +} + +func LoadSuspension(root, sessionID, interactionID string) (Suspension, error) { + b, err := os.ReadFile(suspensionPath(root, sessionID, 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 +} + +func ResolveSuspension(root string, s Suspension, 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) + } + s.Resolved = true + p := suspensionPath(root, s.SessionID, s.InteractionID) + b, err := json.Marshal(s) + if err != nil { + return nil, err + } + if err := atomicfile.WriteFile(p, b, 0o600); err != nil { + return nil, err + } + return []wire.Message{s.Assistant, {Role: "user", Blocks: []wire.Block{result}}}, nil +} diff --git a/internal/agent/runtime/continuation_test.go b/internal/agent/runtime/continuation_test.go new file mode 100644 index 0000000..dfc7efa --- /dev/null +++ b/internal/agent/runtime/continuation_test.go @@ -0,0 +1,54 @@ +package runtime + +import ( + "encoding/json" + "testing" + + "github.com/memcode-ai/memcode/internal/wire" +) + +func TestSuspensionRoundTripPreservesReasoningAndTool(t *testing.T) { + root := 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 := SaveSuspension(root, s); err != nil { + t.Fatal(err) + } + got, err := LoadSuspension(root, "session-1", "interaction-1") + if err != nil { + t.Fatal(err) + } + if got.Assistant.Blocks[0].Signature != "sig" || got.ToolUseID != "tool-1" { + t.Fatalf("suspension=%+v", got) + } + msgs, err := ResolveSuspension(root, 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 := LoadSuspension(root, "session-1", "interaction-1"); err == nil { + t.Fatal("resolved suspension loaded again") + } +} + +func TestSuspensionRejectsMixedBatchAndMismatchedResult(t *testing.T) { + root := t.TempDir() + mixed := wire.Message{Role: "assistant", Blocks: []wire.Block{{Type: "tool_use", ID: "a"}, {Type: "tool_use", ID: "b"}}} + if err := SaveSuspension(root, Suspension{SessionID: "s", InteractionID: "i", ToolUseID: "a", Assistant: mixed}); err == nil { + t.Fatal("mixed tool batch accepted") + } + single := wire.Message{Role: "assistant", Blocks: []wire.Block{{Type: "tool_use", ID: "a", Name: "approval"}}} + s := Suspension{SessionID: "s", InteractionID: "i2", ToolUseID: "a", Assistant: single} + if err := SaveSuspension(root, s); err != nil { + t.Fatal(err) + } + loaded, err := LoadSuspension(root, "s", "i2") + if err != nil { + t.Fatal(err) + } + if _, err := ResolveSuspension(root, loaded, wire.Block{Type: "tool_result", ToolUseID: "wrong"}); err == nil { + t.Fatal("mismatched result accepted") + } +} diff --git a/internal/agent/runtime/exec.go b/internal/agent/runtime/exec.go index d004efd..4b3e902 100644 --- a/internal/agent/runtime/exec.go +++ b/internal/agent/runtime/exec.go @@ -315,6 +315,8 @@ func (s *Session) dispatch(ctx context.Context, u wire.Block) toolResult { return s.mcpPromptTool(ctx, u.Input) case tools.GwOverview, tools.GwChannel, tools.GwPairing, tools.GwProject, tools.GwAgent, tools.GwSchedule, tools.GwService: return s.adminTool(ctx, u.Name, u.Input) + case tools.PaOverview, tools.PaObjective, tools.PaPolicy, tools.PaResource, tools.PaTrigger, tools.PaWake, tools.PaInbox, tools.PaAnswer, tools.PaHistory, tools.PaLifecycle: + return s.personalTool(ctx, u.Name, u.Input) case tools.GitHub: return s.githubTool(ctx, u.Input) case tools.RunTests: @@ -708,6 +710,17 @@ func reviewTool(name string) bool { // toolDefs returns the tools advertised to the model for the current mode. func (s *Session) toolDefs() []wire.ToolDef { + if s.personalMode { + // Personal cockpit: the pa_* registry plus ask_user. No repo/coding/shell + // tools — management goes through typed, gated pa_* operations. + defs := tools.PersonalDefs() + for _, d := range tools.Defs() { + if d.Name == tools.AskUser { + defs = append(defs, d) + } + } + return defs + } if s.adminMode { // Admin sessions get the admin registry plus a small file surface for // agent homes (instructions, memory, skills): read/edit/search/bash diff --git a/internal/agent/runtime/prompts.go b/internal/agent/runtime/prompts.go index 353845a..9c43a01 100644 --- a/internal/agent/runtime/prompts.go +++ b/internal/agent/runtime/prompts.go @@ -154,6 +154,9 @@ func randomPersonality() string { // chatSpec is the INTERACTIVE session prompt (TUI). func (s *Session) chatSpec(overview string) promptSpec { + if s.personalMode { + return promptSpec{mode: "personal_admin", facts: s.baseFacts()} + } if s.adminMode { return promptSpec{mode: "admin", facts: s.baseFacts()} } diff --git a/internal/agent/runtime/runtime.go b/internal/agent/runtime/runtime.go index 1c6e50b..6c47fc8 100644 --- a/internal/agent/runtime/runtime.go +++ b/internal/agent/runtime/runtime.go @@ -106,6 +106,7 @@ type Session struct { browserHeadless bool // gateway/service sessions run Chrome headless (no desktop) noApprover bool // detached job: no human can answer approval prompts adminMode bool // admin session (`memcode admin`): admin tools only, settings doctrine + personalMode bool // personal cockpit (`memcode personal`): pa_* tools only, personal doctrine adminExec AdminExecutor // cmd-injected admin operations (engine never imports the gateway layer) forceEscalate bool // strong-tier agent: pin every request to the strong vendor (balanced tier) forceFrontier bool // long-running (background) agent: pin every request to the FRONTIER tier @@ -366,6 +367,20 @@ func (s *Session) SetAdmin(exec AdminExecutor) { s.adminExec = exec } +// SetPersonal switches this session into the Personal Agents cockpit: pa_* tools +// only (same injected-executor seam as admin), personal doctrine. +func (s *Session) SetPersonal(exec AdminExecutor) { + s.personalMode = true + s.adminExec = exec +} + +// Personal reports whether this is a personal-cockpit session. +func (s *Session) Personal() bool { return s.personalMode } + +// Restricted reports whether the session is a restricted management console +// (admin or personal cockpit): a limited slash whitelist, no repo/coding tools. +func (s *Session) Restricted() bool { return s.adminMode || s.personalMode } + // Admin reports whether this is an admin session (the TUI swaps its slash set). func (s *Session) Admin() bool { return s.adminMode } diff --git a/internal/agent/tools/admin.go b/internal/agent/tools/admin.go index 77ff69a..81f0450 100644 --- a/internal/agent/tools/admin.go +++ b/internal/agent/tools/admin.go @@ -52,10 +52,11 @@ 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 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). Optional kind=personal creates a Personal Agent, whose objective and lifecycle must be managed with memcode personal. Bind ordinary 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.", InputSchema: obj(map[string]any{ "action": str("add, remove, model, reasoning, or tools"), "name": str("agent name, e.g. personal, coder, researcher"), + "kind": str("add only: empty for an ordinary agent, or personal; Personal lifecycle is managed with memcode personal"), "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"), diff --git a/internal/agent/tools/personal.go b/internal/agent/tools/personal.go new file mode 100644 index 0000000..d96806d --- /dev/null +++ b/internal/agent/tools/personal.go @@ -0,0 +1,111 @@ +package tools + +import "github.com/memcode-ai/memcode/internal/wire" + +// Personal cockpit toolset — the `memcode personal` interactive session's ONLY +// typed operations (plus ask_user). Deterministic management of Personal Agents: +// objectives, policies, resources, triggers, wakes, and pending interactions. +const ( + PaOverview = "pa_overview" // list all Personal Agents with status + PaObjective = "pa_objective" // show/set an agent's objective + PaPolicy = "pa_policy" // stage/show/approve delegation policies + PaResource = "pa_resource" // grant/list/revoke resources + PaTrigger = "pa_trigger" // add/list/pause/resume wake triggers + PaWake = "pa_wake" // run one bounded wake now + PaInbox = "pa_inbox" // list pending human interactions + PaAnswer = "pa_answer" // answer a pending interaction + PaHistory = "pa_history" // recent runs + journaled actions + PaLifecycle = "pa_lifecycle" // pause/resume/stop/delete an agent +) + +// PersonalDefs returns the personal-cockpit tool registry. +func PersonalDefs() []wire.ToolDef { + return []wire.ToolDef{ + { + Name: PaOverview, + Description: "List all Personal Agents: objective, status, approved policy version, pending questions, next wake. Call this first to answer questions about current state.", + InputSchema: obj(map[string]any{}), + }, + { + Name: PaObjective, + Description: "Show or change an agent's objective (the durable desired outcome + success criteria). action=show or action=set with text.", + InputSchema: obj(map[string]any{ + "agent": str("agent name"), + "action": str("show or set"), + "text": str("set only: the objective text"), + }, "agent", "action"), + }, + { + Name: PaPolicy, + Description: "Manage an agent's delegation policy. action=show (approved), action=stage (write a draft from a JSON policy doc in 'document'), action=approve (by hash). Consequential work is 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"), + "hash": str("approve only: the policy hash or prefix"), + }, "agent", "action"), + }, + { + Name: PaResource, + Description: "Grant or revoke a resource. action=grant (type, locator, mode), action=list, action=revoke (id). Filesystem paths are canonicalized and symlink-resolved.", + InputSchema: obj(map[string]any{ + "agent": str("agent name"), + "action": str("grant, list, or revoke"), + "type": str("grant only: filesystem, mcp, command, channel, repository"), + "locator": str("grant only: the path or identifier"), + "mode": str("grant only: read, write, or admin"), + "id": str("revoke only: the resource id"), + }, "agent", "action"), + }, + { + Name: PaTrigger, + Description: "Manage wake triggers. action=add (kind: interval|cron|one-shot, spec), action=list, action=pause/resume (id).", + InputSchema: obj(map[string]any{ + "agent": str("agent name"), + "action": str("add, list, pause, or resume"), + "kind": str("add only: interval, cron, or one-shot"), + "spec": str("add only: e.g. 30m, '0 * * * *', or RFC3339"), + "id": str("pause/resume only: the trigger id"), + }, "agent", "action"), + }, + { + Name: PaWake, + Description: "Run one bounded wake for an agent right now. Fails closed if no policy is approved. Returns the run's status and report.", + InputSchema: obj(map[string]any{ + "agent": str("agent name"), + }, "agent"), + }, + { + Name: PaInbox, + Description: "List an agent's pending human interactions (questions it is suspended waiting on).", + InputSchema: obj(map[string]any{ + "agent": str("agent name"), + }, "agent"), + }, + { + Name: PaAnswer, + Description: "Answer a pending interaction, resuming the suspended run with the exact continuation.", + InputSchema: obj(map[string]any{ + "agent": str("agent name"), + "id": str("the interaction id"), + "answer": str("the human's answer"), + }, "agent", "id", "answer"), + }, + { + Name: PaHistory, + Description: "Show an agent's recent runs and journaled consequential actions.", + InputSchema: obj(map[string]any{ + "agent": str("agent name"), + }, "agent"), + }, + { + Name: PaLifecycle, + Description: "Change an agent's lifecycle. action=pause, resume, stop, or delete. delete removes the config entry but keeps the agent home unless delete_home=true.", + InputSchema: obj(map[string]any{ + "agent": str("agent name"), + "action": str("pause, resume, stop, or delete"), + "delete_home": str("delete only: 'true' to also permanently delete the agent home"), + }, "agent", "action"), + }, + } +} 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/controller.go b/internal/browser/controller.go new file mode 100644 index 0000000..10ccc0c --- /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; Personal 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..214e282 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 "personal": + // Domain-general Personal Agent executive. No repo root required — the + // agent operates over granted environment resources, not a checkout. + base = strings.Join([]string{ + `You are a Personal Agent's bounded executive advancing one long-lived objective toward its success criteria, 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 note_fact. 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 @@ -413,6 +429,8 @@ read before acting, but do NOT assume it is complete or current — verify with }, "\n\n") case "admin": base = adminDoctrine + case "personal_admin": + base = personalAdminDoctrine case "cold": // The A/B baseline: deliberately a vanilla tool agent, no doctrine. base = fmt.Sprintf(`You are a coding assistant working in the repository at %s. @@ -652,6 +670,21 @@ Rules: - 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.` +const personalAdminDoctrine = `You are the memcode Personal Agents cockpit — you manage the user's long-lived Personal Agents by conversation in an interactive terminal session. You are not a coding agent; you are the control room for Personal Agents. + +You manage: objectives, delegation policies, resource grants, wake triggers, bounded wakes, pending human interactions (questions agents are suspended on), run history, and agent lifecycle. + +Rules: +- Changes go through the typed pa_* tools, never by hand-editing files: pa_overview, pa_objective, pa_policy, pa_resource, pa_trigger, pa_wake, pa_inbox, pa_answer, pa_history, pa_lifecycle. +- A Personal Agent can only do consequential work after a policy is approved. To enable one: pa_policy action=stage with a DelegationPolicy JSON, then pa_policy action=approve with the returned hash. Explain that approval gates authority. +- Resources are explicit grants. Use pa_resource to grant a filesystem path (canonicalized, symlink-resolved) with read/write/admin mode; revoke to cut access at the next dispatch. +- pa_wake runs one bounded wake now; pa_trigger adds recurring wakes the gateway fires. A wake ends by reporting, scheduling the next wake, or suspending with a question. +- When an agent is suspended waiting for a human, pa_inbox lists the pending question and pa_answer resumes it with the exact continuation. +- Start from reality: call pa_overview before answering questions about current state; never answer from assumption. +- Mutations run through an approval gate the user sees. State the change plainly. +- When a request is ambiguous (which agent, what objective, what spec), use ask_user rather than guessing. +- Stay in scope: for coding tasks point the user at the normal memcode session; for gateway/channel config point them at memcode admin.` + 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 the evidence — recent commits, uncommitted changes, where they left off. diff --git a/internal/gateway/config/config.go b/internal/gateway/config/config.go index d0ba216..0e90115 100644 --- a/internal/gateway/config/config.go +++ b/internal/gateway/config/config.go @@ -96,6 +96,10 @@ 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 { + // Kind selects additive runtime behavior. Empty preserves the ordinary named + // agent behavior. "personal" enables the Personal Agent runtime; its mutable + // state remains in the agent home rather than gateway.yaml. + Kind string `yaml:"kind,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 +396,29 @@ 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.Kind != "" && agent.Kind != "personal" { + return fmt.Errorf("agent %q has unknown kind %q", id, agent.Kind) + } + } + return nil +} + // 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..e258d1f 100644 --- a/internal/gateway/config/config_test.go +++ b/internal/gateway/config/config_test.go @@ -92,6 +92,26 @@ func TestAllowed(t *testing.T) { } } +func TestAgentKindCompatibilityAndValidation(t *testing.T) { + legacy := Settings{Agents: map[string]Agent{"ordinary": {Model: "m"}}} + if err := legacy.Validate(); err != nil { + t.Fatalf("legacy empty kind must remain valid: %v", err) + } + if got := legacy.Agents["ordinary"].Kind; got != "" { + t.Fatalf("legacy kind = %q, want empty", got) + } + + personal := Settings{Agents: map[string]Agent{"executive": {Kind: "personal"}}} + if err := personal.Validate(); err != nil { + t.Fatalf("personal kind rejected: %v", err) + } + + unknown := Settings{Agents: map[string]Agent{"bad": {Kind: "workflow"}}} + if err := unknown.Validate(); err == nil { + t.Fatal("unknown agent kind must be rejected") + } +} + func TestGetZeroValue(t *testing.T) { var s Settings // nil Channels map if got := s.Get("telegram"); !reflect.DeepEqual(got, Channel{}) { diff --git a/internal/gateway/server/personal.go b/internal/gateway/server/personal.go new file mode 100644 index 0000000..b1bf470 --- /dev/null +++ b/internal/gateway/server/personal.go @@ -0,0 +1,157 @@ +package server + +import ( + "context" + "fmt" + "io" + "time" + + "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/personal" + "github.com/memcode-ai/memcode/internal/provider" +) + +const personalRoutePrefix = "personal:" + +// personalChannel is the internal wake route for Personal Agents. It has no +// external sender; byName gets a discard entry so Deliver routes and runJob +// handles the executive inline. +const personalChannelName = "personal" + +func hasPersonalAgents(settings gwconfig.Settings) bool { + for _, agent := range settings.Agents { + if agent.Kind == "personal" { + return true + } + } + return false +} + +// personalWakeLoop polls each Personal Agent's durable triggers and enqueues a +// wake for any that are due. Claims are atomic (ClaimDueTrigger), so a fired +// trigger advances its next_due and cannot double-fire across restarts. +// +// It keeps one open *personal.Store per agent for the life of the loop instead +// of opening and closing a connection (full PRAGMA setup + migration check) on +// every 15s 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 is removed from config. +func (r *runtime) personalWakeLoop(ctx context.Context) { + stores := map[string]*personal.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.fireDuePersonalTriggers(ctx, stores) + } +} + +func (r *runtime) fireDuePersonalTriggers(ctx context.Context, stores map[string]*personal.Store) { + settings := r.cfg() + now := time.Now().UTC() + live := map[string]bool{} + for id, agent := range settings.Agents { + if agent.Kind != "personal" { + continue + } + live[id] = true + st := stores[id] + if st == nil { + home, err := gwconfig.AgentHome(id) + if err != nil { + continue + } + st, err = personal.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 trigger. + claimed, ok, err := st.ClaimDueTrigger(ctx, t.ID, now) + if err != nil || !ok { + continue + } + text := fmt.Sprintf("wake for trigger %s (%s)", claimed.ID, claimed.Kind) + if err := r.enqueuePersonalWake(ctx, id, text); err != nil { + fmt.Fprintf(r.out, "gateway: personal wake for %s failed: %v\n", id, err) + } + } + } + // An agent removed (or reconfigured away from kind=personal) 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) enqueuePersonalWake(ctx context.Context, agentID, text string) error { + a, ok := r.cfg().Agents[agentID] + if !ok || a.Kind != "personal" { + return fmt.Errorf("no Personal Agent %q", agentID) + } + return r.Deliver(ctx, channels.Inbound{Channel: personalChannelName, Conversation: agentID, Principal: personalRoutePrefix + agentID, Text: text, Trusted: true, MessageID: fmt.Sprintf("wake-%d", time.Now().UnixNano())}) +} + +// personalSink is the discard reply target for the internal personal channel: +// executive output is journaled in the agent home, so there is nothing to send. +type personalSink struct{ out io.Writer } + +func (personalSink) Name() string { return personalChannelName } +func (s personalSink) Send(ctx context.Context, _ string, ob channels.Outbound) error { + fmt.Fprintf(s.out, "gateway: personal: %s\n", truncate(ob.Text, 120)) + return nil +} + +// runPersonalWake executes one Personal Agent executive wake inline and returns +// its report as the (discarded) reply. Policy-gated: no approved policy → a +// blocked report, never a run. +func (r *runtime) runPersonalWake(ctx context.Context, agentID string) string { + home, err := gwconfig.AgentHome(agentID) + if err != nil { + return "error: " + err.Error() + } + st, err := personal.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 := &personal.Executive{Store: st, Home: home, AgentID: agentID, 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..c2e725c 100644 --- a/internal/gateway/server/scheduler_test.go +++ b/internal/gateway/server/scheduler_test.go @@ -11,6 +11,18 @@ import ( "github.com/memcode-ai/memcode/internal/gateway/state" ) +func TestHasPersonalAgents(t *testing.T) { + if hasPersonalAgents(gwconfig.Settings{}) { + t.Fatal("empty settings reported Personal Agents") + } + if hasPersonalAgents(gwconfig.Settings{Agents: map[string]gwconfig.Agent{"ordinary": {}}}) { + t.Fatal("ordinary agent reported as personal") + } + if !hasPersonalAgents(gwconfig.Settings{Agents: map[string]gwconfig.Agent{"executive": {Kind: "personal"}}}) { + t.Fatal("Personal 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..cae6ad1 100644 --- a/internal/gateway/server/server.go +++ b/internal/gateway/server/server.go @@ -162,9 +162,19 @@ func Run(ctx context.Context, root string, mainStore store.Store, settings gwcon for _, ch := range chs { rt.byName[ch.Name()] = ch } + // Personal Agents get an internal wake route: no external sender, output is + // journaled to the agent home, so register a discard sink so Deliver routes. + // Registered unconditionally (not gated on hasPersonalAgents at boot) because + // byName is built once here and never mutated again — a Personal Agent added + // later via a hot-reloaded config must still have somewhere for its wakes to + // route without requiring a gateway restart. + rt.byName[personalChannelName] = personalSink{out: out} webhooks := startWebhooks(ctx, settings, rt, out) + if len(chs) == 0 && !webhooks && !hasPersonalAgents(settings) { + return fmt.Errorf("no channels or Personal Agents configured — run `memcode gateway setup` or `memcode personal create`") + } 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 Personal Agents (no external channels configured)") } for _, ch := range chs { ch := ch @@ -177,6 +187,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 wake loop, even if no Personal Agents exist at boot: it + // re-reads settings via r.cfg() every tick, so an agent (and trigger) added + // later through a hot-reloaded config is picked up without a restart. + go rt.personalWakeLoop(ctx) // durable personal triggers feed the same inbox rt.runWorker(ctx) // blocks until ctx is cancelled if rt.sched != nil { @@ -531,6 +545,17 @@ func (r *runtime) runJob(ctx context.Context, it state.Item) { _ = r.gw.MarkDone(ctx, it.Channel, it.MessageID) return } + // Personal Agent wakes run the bounded executive inline (not a detached coding + // job): the executive owns its policy gate, journal, and continuation state. + if it.Channel == personalChannelName { + report := r.runPersonalWake(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 personal wake for %s failed: %v\n", it.Conversation, err) + return + } + r.deliverReply(ctx, it, report) // personalSink 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..2d54d01 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,22 @@ 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 formatTime(t *time.Time) any { + if t == nil { + return nil + } + return t.UTC().Format(time.RFC3339Nano) +} + 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/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..52128e8 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,23 @@ 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"` + InteractionID string `json:"interaction_id,omitempty"` + WaitingReason string `json:"waiting_reason,omitempty"` + ContinuationVersion int `json:"continuation_version,omitempty"` + WaitingAt time.Time `json:"waiting_at,omitempty"` + ResumedAt time.Time `json:"resumed_at,omitempty"` } // processMatches reports whether the job's recorded pid is alive AND still the same process @@ -92,6 +127,16 @@ 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" self, err := os.Executable() if err != nil { return Job{}, fmt.Errorf("locating memcode binary: %w", err) @@ -144,6 +189,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 +200,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/personal/action.go b/internal/personal/action.go new file mode 100644 index 0000000..7e0761a --- /dev/null +++ b/internal/personal/action.go @@ -0,0 +1,100 @@ +package personal + +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 +} +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/personal/action_test.go b/internal/personal/action_test.go new file mode 100644 index 0000000..b1a6f2b --- /dev/null +++ b/internal/personal/action_test.go @@ -0,0 +1,44 @@ +package personal + +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/personal/crud.go b/internal/personal/crud.go new file mode 100644 index 0000000..3e29ea9 --- /dev/null +++ b/internal/personal/crud.go @@ -0,0 +1,340 @@ +package personal + +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 +} + +// --- Facts --- + +func (s *Store) InsertFact(ctx context.Context, f Fact) error { + now := time.Now().UTC() + _, err := s.db.ExecContext(ctx, `INSERT INTO facts(id,objective_id,key,value_json,source,evidence_json,confidence,confirmed,scope,sensitivity,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`, + f.ID, f.ObjectiveID, f.Key, string(f.Value), f.Source, jsonOr(f.Evidence, "[]"), f.Confidence, boolInt(f.Confirmed), f.Scope, f.Sensitivity, stamp(now), stamp(now)) + return err +} + +func (s *Store) ListFacts(ctx context.Context, objectiveID string) ([]Fact, error) { + rows, err := s.db.QueryContext(ctx, `SELECT id,objective_id,key,value_json,source,evidence_json,confidence,confirmed,scope,sensitivity,created_at,updated_at FROM facts WHERE objective_id=? ORDER BY created_at`, objectiveID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Fact + for rows.Next() { + var f Fact + var value, evidence, created, updated string + var confirmed int + if err := rows.Scan(&f.ID, &f.ObjectiveID, &f.Key, &value, &f.Source, &evidence, &f.Confidence, &confirmed, &f.Scope, &f.Sensitivity, &created, &updated); err != nil { + return nil, err + } + f.Value, f.Evidence = json.RawMessage(value), json.RawMessage(evidence) + f.Confirmed = confirmed != 0 + f.CreatedAt, _ = time.Parse(time.RFC3339Nano, created) + f.UpdatedAt, _ = time.Parse(time.RFC3339Nano, updated) + out = append(out, f) + } + return out, rows.Err() +} + +func boolInt(b bool) int { + if b { + return 1 + } + return 0 +} + +// --- 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,''),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, &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/personal/delegation.go b/internal/personal/delegation.go new file mode 100644 index 0000000..5837ee7 --- /dev/null +++ b/internal/personal/delegation.go @@ -0,0 +1,57 @@ +package personal + +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 +} + +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") + } + if !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/personal/delegation_test.go b/internal/personal/delegation_test.go new file mode 100644 index 0000000..0918228 --- /dev/null +++ b/internal/personal/delegation_test.go @@ -0,0 +1,37 @@ +package personal + +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/personal/environment.go b/internal/personal/environment.go new file mode 100644 index 0000000..a226118 --- /dev/null +++ b/internal/personal/environment.go @@ -0,0 +1,15 @@ +package personal + +import "encoding/json" + +type Observation struct { + ResourceID, Kind string + Value json.RawMessage + Evidence []string + Sensitive bool +} +type Environment struct { + Resources []ResourceGrantModel + Facts []StructuredFact + Observations []Observation +} diff --git a/internal/personal/executive.go b/internal/personal/executive.go new file mode 100644 index 0000000..c000487 --- /dev/null +++ b/internal/personal/executive.go @@ -0,0 +1,64 @@ +package personal + +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/personal/executive_test.go b/internal/personal/executive_test.go new file mode 100644 index 0000000..60dd54b --- /dev/null +++ b/internal/personal/executive_test.go @@ -0,0 +1,56 @@ +package personal + +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/personal/facts.go b/internal/personal/facts.go new file mode 100644 index 0000000..948fc3d --- /dev/null +++ b/internal/personal/facts.go @@ -0,0 +1,17 @@ +package personal + +import "encoding/json" + +type StructuredFact struct { + Key string + Value json.RawMessage + Source string + Evidence []string + Confidence float64 + Confirmed bool + Sensitivity, Scope string +} + +func (f StructuredFact) UsableForExternalRepresentation(policyAllowsInferred bool) bool { + return f.Confirmed || policyAllowsInferred +} diff --git a/internal/personal/generated.go b/internal/personal/generated.go new file mode 100644 index 0000000..8846f15 --- /dev/null +++ b/internal/personal/generated.go @@ -0,0 +1,83 @@ +package personal + +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/personal/interactions.go b/internal/personal/interactions.go new file mode 100644 index 0000000..13c8dc4 --- /dev/null +++ b/internal/personal/interactions.go @@ -0,0 +1,111 @@ +package personal + +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/personal/migrations/002_interactions.sql b/internal/personal/migrations/002_interactions.sql new file mode 100644 index 0000000..0838ab7 --- /dev/null +++ b/internal/personal/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/personal/model.go b/internal/personal/model.go new file mode 100644 index 0000000..8b4a110 --- /dev/null +++ b/internal/personal/model.go @@ -0,0 +1,77 @@ +// Package personal implements the domain-general durable state and runtime +// primitives for Personal Agents. +package personal + +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 Fact struct { + ID, ObjectiveID, Key, Source, Scope, Sensitivity string + Value, Evidence json.RawMessage + Confidence float64 + Confirmed bool + CreatedAt, UpdatedAt time.Time +} + +type Action struct { + ID, ObjectiveID, SubgoalID, RunID, Kind, Target, ConsequenceClass, PolicyHash, Status, IdempotencyKey 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/personal/pacing.go b/internal/personal/pacing.go new file mode 100644 index 0000000..515c368 --- /dev/null +++ b/internal/personal/pacing.go @@ -0,0 +1,68 @@ +package personal + +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/personal/pacing_test.go b/internal/personal/pacing_test.go new file mode 100644 index 0000000..c02b137 --- /dev/null +++ b/internal/personal/pacing_test.go @@ -0,0 +1,37 @@ +package personal + +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/personal/policy.go b/internal/personal/policy.go new file mode 100644 index 0000000..ef45ff8 --- /dev/null +++ b/internal/personal/policy.go @@ -0,0 +1,136 @@ +package personal + +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/personal/policy_test.go b/internal/personal/policy_test.go new file mode 100644 index 0000000..c1080de --- /dev/null +++ b/internal/personal/policy_test.go @@ -0,0 +1,55 @@ +package personal + +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/personal/resources.go b/internal/personal/resources.go new file mode 100644 index 0000000..f70db62 --- /dev/null +++ b/internal/personal/resources.go @@ -0,0 +1,104 @@ +package personal + +import ( + "fmt" + "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 + } + info, err := os.Stat(resolved) + if err != nil { + return "", err + } + if !info.IsDir() { + return "", fmt.Errorf("resource root %s is not a directory", resolved) + } + 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/personal/resources_test.go b/internal/personal/resources_test.go new file mode 100644 index 0000000..65295ac --- /dev/null +++ b/internal/personal/resources_test.go @@ -0,0 +1,79 @@ +package personal + +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") + } +} +func TestConfirmedFactsGateExternalRepresentation(t *testing.T) { + if (StructuredFact{}).UsableForExternalRepresentation(false) { + t.Fatal("unconfirmed fact allowed") + } + if !(StructuredFact{Confirmed: true}).UsableForExternalRepresentation(false) { + t.Fatal("confirmed fact denied") + } + if !(StructuredFact{}).UsableForExternalRepresentation(true) { + t.Fatal("policy-authorized inferred fact denied") + } +} + +// 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/personal/runner.go b/internal/personal/runner.go new file mode 100644 index 0000000..6cd98b2 --- /dev/null +++ b/internal/personal/runner.go @@ -0,0 +1,106 @@ +package personal + +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/personal/runner_exec.go b/internal/personal/runner_exec.go new file mode 100644 index 0000000..2f89522 --- /dev/null +++ b/internal/personal/runner_exec.go @@ -0,0 +1,600 @@ +package personal + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/memcode-ai/memcode/internal/atomicfile" + "github.com/memcode-ai/memcode/internal/llm" + "github.com/memcode-ai/memcode/internal/wire" +) + +// Executive is one Personal Agent's bounded decision loop. 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 + Runner *llm.Runner + Now func() time.Time + MaxSteps 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: "note_fact", + Description: "Record a structured fact about the environment with evidence. Facts gate later external representation: only confirmed facts may be presented externally.", + InputSchema: obj(map[string]any{ + "key": strProp("fact key, e.g. environment.deps.outdated_count"), + "value": map[string]any{"type": "string", "description": "JSON-encoded value"}, + "source": strProp("where this was observed"), + "confirmed": map[string]any{"type": "boolean", "description": "true only if directly verified"}, + "sensitivity": strProp("public|private|secret"), + }, "key", "value", "source"), + }, + { + 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"), + }, +} + +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() + + obj, ok, err := e.Store.GetObjective(ctx, "primary") + if err != nil || !ok { + return RunOutcome{}, fmt.Errorf("no primary objective") + } + if obj.Status != "active" && obj.Status != "draft" { + return RunOutcome{Status: "blocked", Report: "objective is " + obj.Status}, 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 you run `memcode personal approve-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: "personal", + 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 { + o, ok, err := e.Store.GetObjective(context.Background(), "primary") + if err != nil || !ok { + return "" + } + var b strings.Builder + fmt.Fprintf(&b, "Objective: %s\n", o.Description) + if o.SuccessCriteria != "" { + fmt.Fprintf(&b, "Success criteria: %s\n", o.SuccessCriteria) + } + fmt.Fprintf(&b, "Policy consequence classes: %v; delegation depth %d.\n", p.ConsequenceClasses, p.MaxDelegationDepth) + if subs, err := e.Store.ListSubgoals(context.Background(), o.ID); 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 facts, err := e.Store.ListFacts(context.Background(), o.ID); err == nil && len(facts) > 0 { + b.WriteString("Known facts:\n") + for _, f := range facts { + fmt.Fprintf(&b, " - %s = %s (source %s)\n", f.Key, string(f.Value), f.Source) + } + } + 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 "note_fact": + var in struct { + Key, Value, Source, Sensitivity string + Confirmed bool + } + if err := json.Unmarshal(call.Input, &in); err != nil { + return toolResult{}, nil, err + } + f := Fact{ID: fmt.Sprintf("fact-%d", now.UnixNano()), ObjectiveID: "primary", Key: in.Key, + Value: json.RawMessage(in.Value), Source: in.Source, Confirmed: in.Confirmed, Sensitivity: in.Sensitivity} + if err := e.Store.InsertFact(ctx, f); err != nil { + return toolResult{}, nil, err + } + return toolResult{content: "fact recorded: " + in.Key}, 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 "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) + } +} + +// 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) +} + +// writeSuspension persists the exact continuation for an ask_user suspension. +// It stores the full message transcript so resume replays nothing already done. +func writeSuspension(home, runID, interactionID string, call wire.Block, assistant wire.Message, msgs []wire.Message) error { + dir := filepath.Join(home, "runs", runID) + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + s := map[string]any{ + "version": 1, "interaction_id": interactionID, "run_id": runID, + "tool_use_id": call.ID, "tool_name": call.Name, "tool_input": json.RawMessage(call.Input), + "assistant": assistant, "messages": msgs, + "created_at": time.Now().UTC().Format(time.RFC3339Nano), "resolved": false, + } + b, err := json.Marshal(s) + if err != nil { + return err + } + return atomicfile.WriteFile(filepath.Join(dir, "suspension-"+interactionID+".json"), b, 0o600) +} + +// 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) { + path := filepath.Join(e.Home, "runs", in.RunID, "suspension-"+in.ID+".json") + b, err := os.ReadFile(path) + if err != nil { + return RunOutcome{}, fmt.Errorf("no continuation for interaction %q: %w", in.ID, err) + } + var s struct { + Resolved bool `json:"resolved"` + ToolUseID string `json:"tool_use_id"` + Messages []wire.Message `json:"messages"` + } + if err := json.Unmarshal(b, &s); err != nil { + return RunOutcome{}, err + } + if s.Resolved { + return RunOutcome{}, fmt.Errorf("interaction %q continuation already resolved", in.ID) + } + // 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) + + // Append the exact tool result matching the suspended tool_use_id. + msgs := append([]wire.Message{}, s.Messages...) + msgs = append(msgs, wire.Message{Role: "user", Blocks: []wire.Block{{ + Type: "tool_result", ToolUseID: s.ToolUseID, Content: answer, + }}}) + + out := e.loop(ctx, in.RunID, policyDoc, pol.Hash, msgs, tools) + + // Mark 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 file already carries the appended answer forward). Only a hard + // resume error (returned above) leaves this continuation retryable. + if out.Status == "completed" || out.Status == "failed" || out.Status == "suspended" { + var raw map[string]any + if json.Unmarshal(b, &raw) == nil { + raw["resolved"] = true + if rb, err := json.Marshal(raw); err == nil { + _ = atomicfile.WriteFile(path, rb, 0o600) + } + } + _ = e.Store.UpdateRunStatus(ctx, in.RunID, out.Status, json.RawMessage(fmt.Sprintf(`{"report":%q}`, out.Report))) + } + return out, nil +} diff --git a/internal/personal/runner_exec_test.go b/internal/personal/runner_exec_test.go new file mode 100644 index 0000000..cc3c9eb --- /dev/null +++ b/internal/personal/runner_exec_test.go @@ -0,0 +1,247 @@ +package personal + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "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} +} + +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", 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", "note_fact", map[string]any{"key": "deps.outdated", "value": "3", "source": "scan", "confirmed": true})}}, + {StopReason: "tool_use", Blocks: []wire.Block{toolUse("t3", "report", map[string]any{"summary": "found 3 outdated deps"})}}, + }} + ex, st, _ := 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 + fact recorded. + subs, _ := st.ListSubgoals(context.Background(), "primary") + if len(subs) != 1 || subs[0].Description != "scan deps" { + t.Fatalf("subgoals=%v", subs) + } + facts, _ := st.ListFacts(context.Background(), "primary") + if len(facts) != 1 || facts[0].Key != "deps.outdated" { + t.Fatalf("facts=%v", facts) + } + // 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) + } + // Continuation file exists. + if _, err := os.Stat(filepath.Join(home, "runs", out.RunID, "suspension-"+out.InteractionID+".json")); err != nil { + t.Fatalf("continuation missing: %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", 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) + } +} + +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/personal/runner_test.go b/internal/personal/runner_test.go new file mode 100644 index 0000000..53890fd --- /dev/null +++ b/internal/personal/runner_test.go @@ -0,0 +1,65 @@ +package personal + +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/personal/scheduler.go b/internal/personal/scheduler.go new file mode 100644 index 0000000..922a652 --- /dev/null +++ b/internal/personal/scheduler.go @@ -0,0 +1,169 @@ +package personal + +import ( + "context" + "database/sql" + "fmt" + "time" + + "github.com/robfig/cron/v3" +) + +type MissedRunPolicy string + +const ( + MissedSkip MissedRunPolicy = "skip" + MissedRunOnce MissedRunPolicy = "run_once" + MissedCatchUp MissedRunPolicy = "catch_up" +) + +func NextDue(kind, spec string, after time.Time) (time.Time, error) { + switch kind { + case "manual": + return time.Time{}, nil + case "interval": + d, err := time.ParseDuration(spec) + if err != nil || d <= 0 { + return time.Time{}, fmt.Errorf("invalid interval %q", spec) + } + return after.Add(d), nil + case "cron": + sch, err := cron.ParseStandard(spec) + if err != nil { + return time.Time{}, err + } + return sch.Next(after), nil + case "one_shot", "next_wake": + return time.Parse(time.RFC3339, spec) + default: + return time.Time{}, fmt.Errorf("unknown trigger kind %q", 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 + } + fired := now.UTC() + var newNext any + if t.Kind != "one_shot" && t.Kind != "next_wake" { + n, e := NextDue(t.Kind, t.Spec, fired) + if e != nil { + return Trigger{}, false, e + } + newNext = stamp(n) + } else { + t.Status = "completed" + } + res, err := tx.ExecContext(ctx, `UPDATE triggers SET status=?,last_fired_at=?,next_due_at=?,updated_at=? WHERE id=? AND last_fired_at IS ?`, t.Status, stamp(fired), newNext, 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/personal/schema.sql b/internal/personal/schema.sql new file mode 100644 index 0000000..23e89e6 --- /dev/null +++ b/internal/personal/schema.sql @@ -0,0 +1,54 @@ +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 facts ( + id TEXT PRIMARY KEY, objective_id TEXT NOT NULL, key TEXT NOT NULL, value_json TEXT NOT NULL, + source TEXT NOT NULL, evidence_json TEXT NOT NULL DEFAULT '[]', confidence REAL NOT NULL DEFAULT 0, + confirmed INTEGER NOT NULL DEFAULT 0, scope TEXT NOT NULL DEFAULT '', sensitivity TEXT NOT NULL DEFAULT '', + 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/personal/store.go b/internal/personal/store.go new file mode 100644 index 0000000..b527f30 --- /dev/null +++ b/internal/personal/store.go @@ -0,0 +1,244 @@ +package personal + +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 + +// 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} + +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 Personal Agent home: %w", err) + } + path := filepath.Join(home, "personal.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 Personal 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", "facts", "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/personal/store_test.go b/internal/personal/store_test.go new file mode 100644 index 0000000..6cf961b --- /dev/null +++ b/internal/personal/store_test.go @@ -0,0 +1,190 @@ +package personal + +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{"personal.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", "facts", "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.InsertFact(ctx, Fact{ID: "f1", ObjectiveID: "o1", Key: "environment.state", Value: json.RawMessage(`{}`), Source: "observation"}); 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", "facts", "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) + if err := s.CreateTrigger(ctx, Trigger{ID: "t1", ObjectiveID: "o1", Kind: "interval", Spec: "5m", 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) + } + 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 || triggers[0].NextDueAt == nil || !triggers[0].NextDueAt.After(now) { + t.Fatalf("triggers=%+v err=%v", triggers, err) + } +} + +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", ""}, {"interval", "5m"}, {"cron", "0 * * * *"}, {"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) + } + } +} + +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/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": From 54eaac3ea78a74ca831db2632485a844aa371eb9 Mon Sep 17 00:00:00 2001 From: Tim Erwin Date: Sun, 30 Aug 2026 13:27:38 +0700 Subject: [PATCH 02/13] personal: delegate consequential work to real capability workers; fix CI lint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the biggest gap from review: the Personal Agent executive had no way to reach browser/MCP/shell/filesystem capability — it was a second, weaker agent runtime sitting beside the real memcode engine instead of commanding it. Two new executive tools fix that: - delegate: spawns a policy-scoped worker as a real detached `memcode run` job (internal/jobs.SpawnWithSpec), using the delegation.go scaffolding (ExecutionEnvelope, ValidateDelegation, PrepareRunDirectory) that already existed but was never called from anywhere. The worker gets whatever toolsets it's granted — browser, MCP, shell, filesystem, skills — same as any ordinary memcode agent, not the executive's fixed 7 tools. Requested toolsets/consequences must be a subset of the agent's own approved policy; MaxDelegationDepth gates whether delegation is allowed at all. - check_delegate: reads a delegated job's status/result back on a later wake (RunOnce is one bounded wake, so the result can't arrive in the same call) and closes out its action-journal entry. Also makes jobs.SpawnSpec.ToolPolicy a REAL restriction instead of recorded- only metadata: new hidden --allow-tools/--deny-tools flags on `memcode run` bind to the same SetToolPolicy enforcement an ordinary gateway-bound agent gets from its config, and SpawnWithSpec now passes them through argv. Scope notes, called out explicitly rather than implied as done: - "browser" toolset reuses the existing ephemeral Chrome session (the same one --chrome already drives) — NOT the user's own already-running Chrome. Attaching to a persistent/remote Chrome needs the broker/RemoteConfig wiring in internal/browser, which is declared but has zero callers and zero implementation; that's separate follow-up work, not done here. - Filesystem/MCP resource grants are not OS-sandboxed to a spawned worker (memcode has no such sandbox anywhere) — governance here is the same approval/audit model as the rest of the codebase (tool-level allow/deny + the action journal), not a hard jail. Also fixes two CI lint failures on the open PR: - internal/gateway/state/state.go: removed formatTime, an unused helper (staticcheck U1000). - internal/personal/store.go: decapitalized a Go error string (ST1005). --- cmd/run.go | 25 ++++ internal/gateway/state/state.go | 7 - internal/jobs/jobs.go | 12 ++ internal/personal/runner_exec.go | 200 ++++++++++++++++++++++++++ internal/personal/runner_exec_test.go | 114 +++++++++++++++ internal/personal/store.go | 2 +- 6 files changed, 352 insertions(+), 8 deletions(-) diff --git a/cmd/run.go b/cmd/run.go index 8960a8b..3a65f99 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -127,6 +127,16 @@ for local gateway development. Never store keys in .memcode.`, sess.SetBrowserEnabled(true) defer sess.CloseBrowser() // tear down Chrome when the one-shot session ends } + // --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 +238,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 +310,10 @@ 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("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/internal/gateway/state/state.go b/internal/gateway/state/state.go index 2d54d01..558dc16 100644 --- a/internal/gateway/state/state.go +++ b/internal/gateway/state/state.go @@ -247,13 +247,6 @@ func (s *Store) SetInboxStatus(ctx context.Context, channel, messageID, from, to return n == 1, err } -func formatTime(t *time.Time) any { - if t == nil { - return nil - } - return t.UTC().Format(time.RFC3339Nano) -} - 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/jobs/jobs.go b/internal/jobs/jobs.go index 52128e8..75cca37 100644 --- a/internal/jobs/jobs.go +++ b/internal/jobs/jobs.go @@ -164,6 +164,18 @@ func SpawnWithSpec(spec SpawnSpec) (Job, error) { 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 + // Personal 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 diff --git a/internal/personal/runner_exec.go b/internal/personal/runner_exec.go index 2f89522..274a223 100644 --- a/internal/personal/runner_exec.go +++ b/internal/personal/runner_exec.go @@ -10,6 +10,7 @@ import ( "time" "github.com/memcode-ai/memcode/internal/atomicfile" + "github.com/memcode-ai/memcode/internal/jobs" "github.com/memcode-ai/memcode/internal/llm" "github.com/memcode-ai/memcode/internal/wire" ) @@ -25,6 +26,12 @@ type Executive struct { 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 { @@ -104,6 +111,23 @@ var executiveToolDefs = []wire.ToolDef{ 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"}, + "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 { @@ -445,6 +469,98 @@ func (e *Executive) execTool(ctx context.Context, runID string, p DelegationPoli } 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) + } + 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 + } + if err := ValidateDelegation(p, env); err != nil { + return toolResult{}, nil, err + } + 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: browserModeFor(in.Toolsets), 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 + } + // Record the job↔action mapping as a fact so check_delegate can find the + // action to complete later; RunOnce is one bounded wake, so the result + // necessarily arrives on a subsequent wake, not this one. + mapping, _ := json.Marshal(map[string]any{"action_id": actID, "task": in.Task, "status": "running"}) + _ = e.Store.InsertFact(ctx, Fact{ID: fmt.Sprintf("fact-%d", now.UnixNano()), ObjectiveID: "primary", + Key: "delegation." + job.ID, Value: mapping, Source: "delegate", Confirmed: true}) + 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.delegationActionID(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 { @@ -477,6 +593,90 @@ func (e *Executive) execTool(ctx context.Context, runID string, p DelegationPoli } } +// 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 +} + +// delegationActionID recovers the action id a delegate call recorded for jobID +// (as a fact, since facts are the only durable log delegate can write to +// without a schema migration), so check_delegate can close it out. +func (e *Executive) delegationActionID(ctx context.Context, jobID string) string { + facts, err := e.Store.ListFacts(ctx, "primary") + if err != nil { + return "" + } + key := "delegation." + jobID + for i := len(facts) - 1; i >= 0; i-- { + if facts[i].Key != key { + continue + } + var v struct { + ActionID string `json:"action_id"` + } + if json.Unmarshal(facts[i].Value, &v) == nil { + return v.ActionID + } + } + return "" +} + +// 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. "browser" here reuses memcode's existing ephemeral Chrome +// session (the same one --chrome already drives for ordinary agents) — NOT +// the user's own already-running Chrome. Attaching a delegated worker to the +// user's real browser needs the broker/remote-controller wiring in +// internal/browser/broker and internal/browser/remote.go, which is declared +// but has no implementation yet. +func browserModeFor(toolsets []string) string { + for _, t := range toolsets { + if t == "browser" { + return "ephemeral" + } + } + 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") diff --git a/internal/personal/runner_exec_test.go b/internal/personal/runner_exec_test.go index cc3c9eb..b497de2 100644 --- a/internal/personal/runner_exec_test.go +++ b/internal/personal/runner_exec_test.go @@ -9,6 +9,7 @@ import ( "testing" "time" + "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" @@ -225,6 +226,119 @@ func TestPolicyApprovalMovesObjectiveActive(t *testing.T) { } } +// 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. +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) + } + + facts, err := st.ListFacts(ctx, "primary") + if err != nil { + t.Fatal(err) + } + var jobID string + for _, f := range facts { + if strings.HasPrefix(f.Key, "delegation.") { + jobID = strings.TrimPrefix(f.Key, "delegation.") + } + } + if jobID == "" { + t.Fatalf("no delegation fact recorded: %v", facts) + } + + 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", 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{ diff --git a/internal/personal/store.go b/internal/personal/store.go index b527f30..473c9e8 100644 --- a/internal/personal/store.go +++ b/internal/personal/store.go @@ -65,7 +65,7 @@ func migrate(ctx context.Context, db *sql.DB) error { return err } if version > len(migrations) { - return fmt.Errorf("Personal Agent schema version %d is newer than supported version %d", 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) From f97cbc2e77b6c731c7b8d94fd24a53b1bf7e9908 Mon Sep 17 00:00:00 2001 From: Tim Erwin Date: Sun, 30 Aug 2026 13:43:52 +0700 Subject: [PATCH 03/13] personal: browser delegation defaults to the user's OWN existing Chrome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Corrects course on the browser gap flagged after the last review: existing- Chrome access is core to Personal Agents, not deferrable follow-up. A Personal Agent's whole premise — acting as the user across their real accounts (Gmail, LinkedIn, an ATS, internal dashboards) — requires the user's actual signed-in session, not a fresh ephemeral profile with no cookies. Per docs/design/personal-agents.md's already-specified "browser broker trust boundary": Personal Agents connect to the user's existing Chrome through a gateway-owned broker and a permission-protected local socket, fail closed on any connection/auth problem, and never silently fall back to another profile. - internal/browser/broker/{server,client}.go: exposes the (already-reviewed) in-process Broker over a Unix socket, so a delegated worker — a SEPARATE OS process spawned via jobs.SpawnWithSpec — can Acquire/Release/OwnPage/ CanMutate against the SAME broker the gateway owns, not a fresh one. Socket is 0600 inside gwconfig.Dir() (0700). Round-trip tested, including that a concurrent Acquire is correctly rejected and a release unblocks the next one. - internal/gateway/server: the gateway now owns one *broker.Broker for its whole lifetime and serves it on that socket unconditionally at startup (cheap — a local listener), so it's there the moment a delegate call needs it, no restart required after `memcode personal browser setup`. - internal/personal: browserModeFor's "browser" toolset now defaults to BrowserExistingChrome, not ephemeral — "browser:ephemeral" is the explicit opt-down for tasks that genuinely want a fresh, logged-out profile. Before spawning anything, the delegate tool verifies the broker is reachable and fails the tool call closed (a normal tool_result error, not a spawned job) if it isn't — verified by test, alongside the success path asserting the spawned job's SpawnSpec actually carries BrowserMode=existing_chrome. - internal/jobs: SpawnWithSpec passes existing_chrome through as --browser-session (+ --browser-agent/--browser-run for the lease identity) instead of --chrome. - cmd/run.go: --browser-session existing_chrome acquires the broker lease itself (via the client) and, only on success, wires an ad-hoc chrome-devtools-mcp --autoConnect MCP server into the session (Session.SetExtraMCPServers, new) — the SAME chrome-devtools-mcp package this browser experience is built on (see internal/browser/remote.go). On any failure it returns an error and exits; it never calls SetBrowserEnabled(true) (ephemeral) as a fallback. - cmd/personal_browser.go: `memcode personal browser setup` checks npx and the broker socket, then attempts a REAL bounded (10s) chrome-devtools-mcp connection and reports the tool count or the actual connect errors — it does not pretend to click Chrome's own "Allow" dialog, which only the user can do. - Also fixes a real bug found while testing this: ValidateDelegation treated an empty parent.AllowedTools as "allows no tools by name", the opposite of the len(...)>0-means-restricted convention Executive.allowedTools already uses — every delegate call with a name-unrestricted policy was rejected. Scope note, stated rather than implied: the literal Chrome-side consent flow (the "Allow" dialog, actually clicking it) cannot be exercised or verified in this environment — no live Chrome, no human to click it. Everything up to that point (broker lifecycle, lease exclusivity, fail-closed wiring, the actual chrome-devtools-mcp CLI invocation, verified against the real published package) is built and tested; the dialog itself needs the user's own machine. --- cmd/personal_browser.go | 112 ++++++++++++++++++++ cmd/run.go | 38 ++++++- internal/agent/runtime/mcp.go | 7 ++ internal/agent/runtime/runtime.go | 11 ++ internal/browser/broker/client.go | 107 +++++++++++++++++++ internal/browser/broker/server.go | 138 +++++++++++++++++++++++++ internal/browser/broker/server_test.go | 69 +++++++++++++ internal/gateway/server/server.go | 50 +++++++-- internal/jobs/jobs.go | 8 ++ internal/personal/delegation.go | 18 +++- internal/personal/runner_exec.go | 39 +++++-- internal/personal/runner_exec_test.go | 133 ++++++++++++++++++++++++ 12 files changed, 707 insertions(+), 23 deletions(-) create mode 100644 cmd/personal_browser.go create mode 100644 internal/browser/broker/client.go create mode 100644 internal/browser/broker/server.go create mode 100644 internal/browser/broker/server_test.go diff --git a/cmd/personal_browser.go b/cmd/personal_browser.go new file mode 100644 index 0000000..94a2376 --- /dev/null +++ b/cmd/personal_browser.go @@ -0,0 +1,112 @@ +package cmd + +import ( + "context" + "fmt" + "os/exec" + "time" + + "github.com/memcode-ai/memcode/internal/browser" + "github.com/memcode-ai/memcode/internal/browser/broker" + "github.com/memcode-ai/memcode/internal/mcp" + "github.com/spf13/cobra" +) + +// personalBrowserCmd groups existing-Chrome setup/diagnostics under +// `memcode personal browser`. Personal Agents default their "browser" +// toolset to the user's OWN already-running Chrome (see +// docs/design/personal-agents.md "Browser broker trust boundary"), not a +// fresh ephemeral profile — this is where that gets configured and verified. +var personalBrowserCmd = &cobra.Command{Use: "browser", Short: "Set up and check existing-Chrome access for delegated Personal Agent workers"} + +var personalBrowserSetupCmd = &cobra.Command{ + Use: "setup", + Short: "Check prerequisites and connect to your already-running Chrome", + Long: `Personal Agents delegate browser work to your OWN already-running, already- +logged-in Chrome — not a fresh profile — so a delegated worker can actually +use accounts you're signed into (Gmail, LinkedIn, an ATS, ...). This requires: + + 1. Chrome 144+. + 2. Remote Debugging enabled: open chrome://inspect/#remote-debugging in + Chrome and toggle Remote Debugging on. + 3. The memcode gateway running (` + "`memcode gateway run`" + `) — it owns the + broker that arbitrates which delegated worker may drive Chrome at a + time, so at most one worker touches it at once. + +This command checks each prerequisite and attempts a real connection. It +does NOT click Chrome's own "Allow" dialog for you — the first connection +attempt after this shows that dialog in Chrome itself, and only you can +approve it. If anything here fails, existing-Chrome delegation fails closed +rather than silently falling back to a fresh, logged-out browser.`, + RunE: func(cmd *cobra.Command, args []string) error { + w := cmd.OutOrStdout() + ok := true + check := func(name string, good bool, detail string) { + mark := "ok" + if !good { + mark = "FAIL" + ok = false + } + fmt.Fprintf(w, " [%s] %s: %s\n", mark, name, detail) + } + + npx, err := exec.LookPath("npx") + check("npx available", err == nil, func() string { + if err != nil { + return "not found on PATH — Node.js is required" + } + return npx + }()) + + 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, func() string { + if reachable { + return sock + } + return "not reachable — start `memcode gateway run` first" + }()) + } + + if !ok { + fmt.Fprintln(w, "\nFix the above, then re-run `memcode personal browser setup`.") + return fmt.Errorf("prerequisites not met") + } + + fmt.Fprintln(w, "\nAttempting a connection to your running Chrome (10s timeout)...") + fmt.Fprintln(w, "If Chrome shows an \"Allow\" dialog, click Allow — that's Chrome's own consent") + fmt.Fprintln(w, "step, not something this command can do for you.") + ctx, cancel := context.WithTimeout(cmd.Context(), 10*time.Second) + defer cancel() + mgr := mcp.Connect(ctx, map[string]mcp.ServerConfig{ + "chrome-devtools": {Type: "stdio", Command: "npx", Args: []string{"-y", browser.ChromeDevToolsMCPPackage, "--autoConnect"}}, + }, mcp.Options{Version: mcpSetupClientVersion}) + defer mgr.Close() + tools := mgr.Tools() + errs := mgr.Errors() + if len(tools) == 0 { + fmt.Fprintln(w, "\n [FAIL] could not connect to Chrome") + for _, e := range errs { + fmt.Fprintf(w, " - %v\n", e) + } + fmt.Fprintln(w, "\nCheck: Chrome 144+, chrome://inspect/#remote-debugging toggled on, Chrome") + fmt.Fprintln(w, "actually running (autoConnect attaches to a running instance, it doesn't") + fmt.Fprintln(w, "launch one), and that you clicked Allow if a dialog appeared.") + return fmt.Errorf("existing-Chrome connection failed") + } + fmt.Fprintf(w, "\n [ok] connected — %d browser tool(s) available\n", len(tools)) + fmt.Fprintln(w, "\nExisting-Chrome delegation is ready. A Personal Agent's delegate calls with") + fmt.Fprintln(w, "toolsets:[\"browser\"] will now use this session by default.") + return nil + }, +} + +const mcpSetupClientVersion = "0.1.0" + +func init() { + personalBrowserCmd.AddCommand(personalBrowserSetupCmd) + personalCmd.AddCommand(personalBrowserCmd) +} diff --git a/cmd/run.go b/cmd/run.go index 3a65f99..01ce45c 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,36 @@ 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 Personal 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 — run `memcode personal browser setup`; 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. @@ -314,6 +344,12 @@ func init() { _ = 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/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 6c47fc8..21480e3 100644 --- a/internal/agent/runtime/runtime.go +++ b/internal/agent/runtime/runtime.go @@ -129,6 +129,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) @@ -388,6 +389,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/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..ac6f1cc --- /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 Personal Agent's delegated +// worker running as a standalone `memcode personal run`, 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/gateway/server/server.go b/internal/gateway/server/server.go index cae6ad1..0190906 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 Personal 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 a Personal Agent's delegate + // call needs it, without requiring a gateway restart after `memcode + // personal browser setup`. 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 diff --git a/internal/jobs/jobs.go b/internal/jobs/jobs.go index 75cca37..91d90b7 100644 --- a/internal/jobs/jobs.go +++ b/internal/jobs/jobs.go @@ -137,6 +137,7 @@ func Spawn(root, task, mode, tier string, chrome, reportBack bool, session strin 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) @@ -161,6 +162,13 @@ func SpawnWithSpec(spec SpawnSpec) (Job, error) { 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) } diff --git a/internal/personal/delegation.go b/internal/personal/delegation.go index 5837ee7..c35e8e5 100644 --- a/internal/personal/delegation.go +++ b/internal/personal/delegation.go @@ -21,8 +21,20 @@ type ExecutionEnvelope struct { 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") @@ -30,7 +42,11 @@ func ValidateDelegation(parent DelegationPolicy, e ExecutionEnvelope) error { if e.DelegationDepth > parent.MaxDelegationDepth { return fmt.Errorf("delegation depth exceeds policy") } - if !subset(e.Toolsets, parent.AllowedTools) { + // 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) { diff --git a/internal/personal/runner_exec.go b/internal/personal/runner_exec.go index 274a223..c17fe61 100644 --- a/internal/personal/runner_exec.go +++ b/internal/personal/runner_exec.go @@ -10,6 +10,7 @@ import ( "time" "github.com/memcode-ai/memcode/internal/atomicfile" + "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" @@ -118,7 +119,7 @@ var executiveToolDefs = []wire.ToolDef{ "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"}, + "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"), @@ -489,16 +490,28 @@ func (e *Executive) execTool(ctx context.Context, runID string, p DelegationPoli } 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 `memcode personal browser setup` not completed) — refusing to fall back to ephemeral Chrome") + } + } actID, err := journaling("delegate", in.Task, delegateConsequence(consequences), call.Input) if err != nil { return toolResult{}, nil, err @@ -519,7 +532,7 @@ func (e *Executive) execTool(ctx context.Context, runID string, p DelegationPoli ToolPolicy: jobs.ToolPolicy{Allowed: in.Toolsets}, Budgets: env.Budgets, AgentID: e.AgentID, ObjectiveID: "primary", RunID: runID, ParentRunID: runID, - PolicyHash: policyHash, BrowserMode: browserModeFor(in.Toolsets), ReportBack: true, + 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) @@ -662,16 +675,22 @@ func delegateMode(cs []ConsequenceClass) string { } // browserModeFor reports the jobs.SpawnSpec.BrowserMode for a requested -// toolset list. "browser" here reuses memcode's existing ephemeral Chrome -// session (the same one --chrome already drives for ordinary agents) — NOT -// the user's own already-running Chrome. Attaching a delegated worker to the -// user's real browser needs the broker/remote-controller wiring in -// internal/browser/broker and internal/browser/remote.go, which is declared -// but has no implementation yet. +// 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 { - if t == "browser" { - return "ephemeral" + switch t { + case "browser", "browser:existing_chrome": + return BrowserExistingChrome + case "browser:ephemeral": + return BrowserEphemeral } } return "" diff --git a/internal/personal/runner_exec_test.go b/internal/personal/runner_exec_test.go index b497de2..87b9473 100644 --- a/internal/personal/runner_exec_test.go +++ b/internal/personal/runner_exec_test.go @@ -9,6 +9,7 @@ import ( "testing" "time" + "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" @@ -234,6 +235,138 @@ func TestPolicyApprovalMovesObjectiveActive(t *testing.T) { // 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. +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 delegation fact should exist. + facts, err := st.ListFacts(ctx, "primary") + if err != nil { + t.Fatal(err) + } + for _, f := range facts { + if strings.HasPrefix(f.Key, "delegation.") { + t.Fatalf("expected no delegation to have been spawned without a broker, got fact %v", f) + } + } + 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) + } + facts, err := st.ListFacts(ctx, "primary") + if err != nil { + t.Fatal(err) + } + var jobID string + for _, f := range facts { + if strings.HasPrefix(f.Key, "delegation.") { + jobID = strings.TrimPrefix(f.Key, "delegation.") + } + } + if jobID == "" { + t.Fatalf("no delegation fact recorded: %v", facts) + } + 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{ From 7abea6c0d01b8ee144abb45d8f204c7104603b78 Mon Sep 17 00:00:00 2001 From: Tim Erwin Date: Sun, 30 Aug 2026 13:57:33 +0700 Subject: [PATCH 04/13] personal: cut resource-grant ceremony, mirror config as files, guide setup via the cockpit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three separate complaints from review, addressed at the right layer each: 1. "It's just a file ref" — `resources add` required a redundant --mode read (already the default) and a mandatory argument even for the overwhelming common case. A bare path is now enough: memcode personal resources add jobhunt ~/resume.md Non-filesystem grants (mcp/command/channel) still need the type spelled out, since there's nothing to infer it from. Also fixes the real bug this surfaced: CanonicalFilesystemGrant rejected any non-directory path, even though PathWithinGrant already handles a single-file grant correctly (rel == "." on an exact match) — you could not actually grant a single file like a resume before this, only whole directories. `personal create` also gained a repeatable --grant flag to fold "create it, then give it something to read" into one command. 2. "Not this CLI-command-argument garbage" — Personal Agent state (objective, policy, resources) lived ONLY in personal.db, reachable exclusively through bespoke commands, unlike every other piece of memcode config (gateway.yaml, .mcp.json, CLAUDE.md) which is a plain file. WriteConfigMirror now regenerates objective.md/policy.yaml/resources.yaml in the agent's home on every mutation — real, readable, diffable files. The run journal (actions/triggers/interactions) deliberately stays in SQLite: it needs atomic claim/complete semantics under concurrent access (gateway wake loop, CLI, and cockpit can all touch the same agent) that flat files don't get for free — a correctness reason, not a habit. 3. "It should gather requirements, do HITL, pre-approve permissions, set up the runtime — like our other agents already do via telegram/model setup" — `memcode personal` (no args) is ALREADY the interactive cockpit (like `memcode admin`) with the pa_* tools wired for exactly this. The actual gap was its system prompt: personalAdminDoctrine listed tool mechanics with no instruction to gather-then-propose-then-approve. Rewrote it to require a real walkthrough for first-time creation — reason about what resources/ toolsets/consequences the objective needs (asking the user rather than guessing), present the concrete plan in plain language BEFORE touching anything, then apply everything at once on approval, including wake cadence (trigger) — an agent with no trigger and no plan to ever be woken is dead on arrival, so cadence is not an optional afterthought either. A prior pass at this (adding a NEW deterministic `memcode personal setup` CLI wizard, mirroring `gateway setup`'s bufio-prompt mechanism) was wrong and reverted before commit — that reproduces the exact CLI-surface- sprawl complaint instead of fixing it. The cockpit conversation IS the wizard; it needed a better prompt, not a new command. --- cmd/personal.go | 27 +++++++++- cmd/personal_policy.go | 4 +- cmd/personal_resources.go | 30 +++++++++-- internal/doctrine/prompts.go | 53 +++++++++++++++++++- internal/personal/mirror.go | 91 ++++++++++++++++++++++++++++++++++ internal/personal/resources.go | 12 ++--- 6 files changed, 204 insertions(+), 13 deletions(-) create mode 100644 internal/personal/mirror.go diff --git a/cmd/personal.go b/cmd/personal.go index cb69ddc..3faa8f4 100644 --- a/cmd/personal.go +++ b/cmd/personal.go @@ -137,7 +137,31 @@ func personalCreate(cmd *cobra.Command, args []string) error { if err := st.CreateObjective(cmd.Context(), personal.Objective{ID: "primary", Description: objective, Status: "draft"}); err != nil { return err } - fmt.Fprintf(cmd.OutOrStdout(), "Created Personal Agent %s. Consequential work remains blocked until its delegation policy is approved.\n", name) + // --grant: fold the common "create it, then give it something to read" two + // steps into one. Filesystem only (type is inferred, same as `resources + // add`) — mcp/command/channel grants still need the fuller form, since + // there's no single flag shape that reads naturally for all four. + grants, _ := cmd.Flags().GetStringArray("grant") + for _, g := range grants { + canon, err := personal.CanonicalFilesystemGrant(g) + if err != nil { + return fmt.Errorf("cannot grant %q: %w", g, err) + } + id := fmt.Sprintf("res-filesystem-%d", time.Now().UnixNano()) + if err := st.InsertResource(cmd.Context(), personal.Resource{ + ID: id, ObjectiveID: "primary", Type: "filesystem", Locator: canon, + AccessMode: "read", AuthorizationSource: "user-cli", Status: "active", + }); err != nil { + return fmt.Errorf("granting %q: %w", g, err) + } + } + _ = personal.WriteConfigMirror(cmd.Context(), home, st) + msg := "Created Personal Agent %s" + if len(grants) > 0 { + msg += fmt.Sprintf(" with read access to %s", strings.Join(grants, ", ")) + } + fmt.Fprintf(cmd.OutOrStdout(), msg+". Consequential work remains blocked until its delegation policy is approved.\n", name) + fmt.Fprintf(cmd.OutOrStdout(), "Config: %s\n", home) return nil } @@ -315,6 +339,7 @@ func personalDelete(cmd *cobra.Command, args []string) error { func init() { create := &cobra.Command{Use: "create ", Args: cobra.MinimumNArgs(2), RunE: personalCreate} + create.Flags().StringArray("grant", nil, "grant read access to a file or directory (repeatable), e.g. --grant ~/resume.md") list := &cobra.Command{Use: "list", Args: cobra.NoArgs, RunE: personalList} show := &cobra.Command{Use: "show ", Args: cobra.ExactArgs(1), RunE: personalShow} pause := &cobra.Command{Use: "pause ", Args: cobra.ExactArgs(1), RunE: personalStatus("paused")} diff --git a/cmd/personal_policy.go b/cmd/personal_policy.go index ab0fabd..41dbefc 100644 --- a/cmd/personal_policy.go +++ b/cmd/personal_policy.go @@ -46,6 +46,7 @@ var personalPolicySetCmd = &cobra.Command{ if err := atomicfile.WriteFile(path, canon, 0o600); err != nil { return err } + _ = personal.WriteConfigMirror(cmd.Context(), home, st) fmt.Fprintf(cmd.OutOrStdout(), "Draft policy v%d staged (hash %s…). Review with `personal policy show %s` then approve with `personal approve-policy %s %s`.\n", ver, hash[:12], args[0], args[0], hash) return nil }, @@ -90,7 +91,7 @@ var personalApprovePolicyCmd = &cobra.Command{ Use: "approve-policy ", Args: cobra.ExactArgs(2), Short: "Approve a staged draft policy by its hash", RunE: func(cmd *cobra.Command, args []string) error { - st, _, err := personalStoreHome(cmd, args[0]) + st, home, err := personalStoreHome(cmd, args[0]) if err != nil { return err } @@ -114,6 +115,7 @@ var personalApprovePolicyCmd = &cobra.Command{ } // Move objective out of draft so scheduled/manual wakes may run. _ = st.SetObjectiveStatus(cmd.Context(), "primary", "active") + _ = personal.WriteConfigMirror(cmd.Context(), home, st) fmt.Fprintf(cmd.OutOrStdout(), "Approved policy %s… for %s; objective is now active.\n", match[:12], args[0]) return nil }, diff --git a/cmd/personal_resources.go b/cmd/personal_resources.go index da85393..4ac30be 100644 --- a/cmd/personal_resources.go +++ b/cmd/personal_resources.go @@ -11,16 +11,36 @@ import ( var personalResourcesCmd = &cobra.Command{Use: "resources", Short: "Manage resource grants"} var personalResourcesAddCmd = &cobra.Command{ - Use: "add ", Args: cobra.ExactArgs(3), + Use: "add [type] ", Args: cobra.RangeArgs(2, 3), Short: "Grant a resource (filesystem path, mcp tool, command, channel)", + Long: `Grant a resource to a Personal Agent. + +For a filesystem path, type is optional and inferred — a bare path is enough: + + memcode personal resources add jobhunt ~/resume.md + +Non-filesystem grants (mcp, command, channel) need the type spelled out: + + memcode personal resources add jobhunt mcp gmail`, RunE: func(cmd *cobra.Command, args []string) error { - st, _, err := personalStoreHome(cmd, args[0]) + st, home, err := personalStoreHome(cmd, args[0]) if err != nil { return err } defer st.Close() mode, _ := cmd.Flags().GetString("mode") - rtype, locator := args[1], args[2] + // Two positional args (agent, locator): type is inferred as filesystem + // when the locator actually resolves to a real path on disk — that's + // the common case (grant a file/dir), and it fails loudly rather than + // guessing when it doesn't resolve. Three args names the type + // explicitly, required for mcp/command/channel (nothing on disk to + // resolve against). + var rtype, locator string + if len(args) == 2 { + rtype, locator = "filesystem", args[1] + } else { + rtype, locator = args[1], args[2] + } if rtype == "filesystem" { canon, err := personal.CanonicalFilesystemGrant(locator) if err != nil { @@ -35,6 +55,7 @@ var personalResourcesAddCmd = &cobra.Command{ }); err != nil { return err } + _ = personal.WriteConfigMirror(cmd.Context(), home, st) fmt.Fprintf(cmd.OutOrStdout(), "Granted %s %s (%s) to %s.\n", rtype, locator, mode, args[0]) return nil }, @@ -67,7 +88,7 @@ var personalResourcesListCmd = &cobra.Command{ var personalResourcesRevokeCmd = &cobra.Command{ Use: "revoke ", Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { - st, _, err := personalStoreHome(cmd, args[0]) + st, home, err := personalStoreHome(cmd, args[0]) if err != nil { return err } @@ -75,6 +96,7 @@ var personalResourcesRevokeCmd = &cobra.Command{ if err := st.SetResourceStatus(cmd.Context(), args[1], "revoked"); err != nil { return err } + _ = personal.WriteConfigMirror(cmd.Context(), home, st) fmt.Fprintf(cmd.OutOrStdout(), "Revoked %s on %s (effective at the next dispatch).\n", args[1], args[0]) return nil }, diff --git a/internal/doctrine/prompts.go b/internal/doctrine/prompts.go index 214e282..bfe77bf 100644 --- a/internal/doctrine/prompts.go +++ b/internal/doctrine/prompts.go @@ -683,7 +683,58 @@ Rules: - Start from reality: call pa_overview before answering questions about current state; never answer from assumption. - Mutations run through an approval gate the user sees. State the change plainly. - When a request is ambiguous (which agent, what objective, what spec), use ask_user rather than guessing. -- Stay in scope: for coding tasks point the user at the normal memcode session; for gateway/channel config point them at memcode admin.` +- Stay in scope: for coding tasks point the user at the normal memcode session; for gateway/channel config point them at memcode admin. + +Creating a new Personal Agent is ONE guided setup conversation that ends with +a fully running agent, not a single tool call and not a pile of separate +manual steps the user has to remember to do themselves. The user states an +objective; you do NOT jump straight to pa_objective and leave everything +else for later. Work it like this, out loud, in the chat: + 1. GATHER REQUIREMENTS: from the stated objective, reason about everything + it will actually need to run — + - Resources: which filesystem paths (a resume, a tracking folder), + which toolsets (browser for job-board/email/site work — default + that to the user's OWN existing, already-logged-in Chrome, not a + fresh profile — mcp servers like gmail, 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, e.g. submitting an application or sending a + message). + - Runtime cadence: how this agent actually gets invoked going + forward — a recurring trigger (interval like "every 6h", a cron + spec like "every morning at 8"), a one-shot, or manual-only (no + trigger; the user runs it themselves with pa_wake). This is NOT + optional to think about — an agent with no trigger and no plan to + ever be woken is dead on arrival. + Ask the user anything genuinely unclear (ask_user) rather than + guessing at scope — especially cadence: don't silently pick "every 5 + minutes" or "never" on your own judgment. + 2. PRESENT: lay out the concrete plan in plain language before touching + anything — the objective as you understand it, each resource you + intend to grant and why, each toolset and consequence class you + intend the policy to allow, the wake cadence you intend to set up, and + what stays out of scope. This is the review surface; the user should + be able to read it and know exactly what authority and what standing + schedule they're about to hand over. + 3. APPROVE & APPLY: only once the user confirms (adjusting anything they + push back on) do you actually build it, completely — pa_objective, + pa_resource grants, pa_policy stage + pa_policy approve for the agreed + policy, AND pa_trigger to set up the agreed cadence (or explicitly none, + if manual-only was agreed). Don't leave triggers as a "you can add this + later" footnote when the user was clear about wanting recurring + behavior — set it up now, in this same conversation. Offer to run + pa_wake once immediately if that fits what they asked for. + 4. Never stage-and-approve a policy the user hasn't seen in plain language + first, and never grant a resource or wake cadence "just in case" beyond + what the stated objective actually needs — narrower is correct, the + user can always grant more later. +This applies to a first-time creation; a later change (adding one more +resource to an existing agent, tightening a policy, adjusting its cadence) +can be a direct, single-step pa_resource/pa_policy/pa_trigger call when the +ask is already that specific — the full walkthrough is for the ambiguous +"here's what I want it to do, figure out what it needs" moment, not every +subsequent tweak.` 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/personal/mirror.go b/internal/personal/mirror.go new file mode 100644 index 0000000..feef094 --- /dev/null +++ b/internal/personal/mirror.go @@ -0,0 +1,91 @@ +package personal + +import ( + "context" + "encoding/json" + "fmt" + "path/filepath" + + yaml "go.yaml.in/yaml/v4" + + "github.com/memcode-ai/memcode/internal/atomicfile" +) + +// WriteConfigMirror regenerates the agent home's human-readable config files +// from the current DB state: objective.md, policy.yaml, resources.yaml. This +// is what makes `ls ~/.memcode/agents//` show something a person can +// actually read, diff, and grep, instead of only a personal.db blob reachable +// through bespoke CLI commands — every OTHER piece of memcode config +// (gateway.yaml, .mcp.json, CLAUDE.md, skills) is a plain file; Personal +// Agents' setup/config surface should be too. +// +// These files are 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 Personal Agent runs unsupervised, so "the document a +// human actually reviewed" must be pinned by hash, not re-derived from +// whatever a file happens to say at wake time. Editing policy.yaml 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, the CLI, and +// the cockpit can all touch the same agent) — a SQL transaction gives +// that almost for free; flat files 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: objective/policy/resources — the SETUP a human decides — mirror out as +// files for inspection. The RUN journal stays in personal.db. Called after +// every mutation to those three (CreateObjective, InsertResource, +// ApprovePolicy, etc.) — best-effort: a mirror failure never blocks the +// underlying DB write, which already succeeded. +func WriteConfigMirror(ctx context.Context, home string, s *Store) error { + obj, hasObj, err := s.GetObjective(ctx, "primary") + if err != nil { + return err + } + if hasObj { + md := fmt.Sprintf("# Objective\n\n%s\n\n**Status:** %s\n", obj.Description, obj.Status) + if obj.SuccessCriteria != "" { + md += fmt.Sprintf("\n**Success criteria:** %s\n", obj.SuccessCriteria) + } + if err := atomicfile.WriteFile(filepath.Join(home, "objective.md"), []byte(md), 0o600); err != nil { + return err + } + } + + policies, err := s.ListPolicies(ctx, "primary") + if err != nil { + return err + } + type policyView struct { + Hash, Status string + Version int + Approved bool + Document map[string]any `yaml:"document"` + } + var pv []policyView + for _, p := range policies { + var doc map[string]any + _ = json.Unmarshal(p.Document, &doc) + pv = append(pv, policyView{Hash: p.Hash, Status: p.Status, Version: p.Version, Approved: p.Status == "approved", Document: doc}) + } + if pb, err := yaml.Marshal(map[string]any{"policies": pv}); err == nil { + _ = atomicfile.WriteFile(filepath.Join(home, "policy.yaml"), pb, 0o600) + } + + res, err := s.ListResources(ctx, "primary") + if err != nil { + return err + } + type resourceView struct { + ID, Type, Locator, AccessMode, Status string + } + var rv []resourceView + for _, r := range res { + rv = append(rv, resourceView{ID: r.ID, Type: r.Type, Locator: r.Locator, AccessMode: r.AccessMode, Status: r.Status}) + } + if rb, err := yaml.Marshal(map[string]any{"resources": rv}); err == nil { + _ = atomicfile.WriteFile(filepath.Join(home, "resources.yaml"), rb, 0o600) + } + return nil +} diff --git a/internal/personal/resources.go b/internal/personal/resources.go index f70db62..9335876 100644 --- a/internal/personal/resources.go +++ b/internal/personal/resources.go @@ -1,7 +1,6 @@ package personal import ( - "fmt" "os" "path/filepath" "strings" @@ -50,13 +49,14 @@ func CanonicalFilesystemGrant(path string) (string, error) { if err != nil { return "", err } - info, err := os.Stat(resolved) - if err != nil { + // 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 } - if !info.IsDir() { - return "", fmt.Errorf("resource root %s is not a directory", resolved) - } return resolved, nil } From 021f53c97a24acaa2dd18f174840627dc88bbc8c Mon Sep 17 00:00:00 2001 From: Tim Erwin Date: Sun, 30 Aug 2026 14:04:25 +0700 Subject: [PATCH 05/13] =?UTF-8?q?personal:=20add=20pa=5Fcreate=20=E2=80=94?= =?UTF-8?q?=20the=20cockpit=20could=20not=20actually=20create=20an=20agent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the whole CLI-surface complaint: the interactive cockpit (`memcode personal`, no args) is supposed to be the entire interface, but its tool set had NO way to create a new Personal Agent. pa_objective only supported show/set, and set required the objective to already exist — creation was only reachable through the CLI's `personal create`. That's why every explanation kept surfacing a CLI command: until now, it was the only path that actually worked. - internal/agent/tools/personal.go: new pa_create tool (agent name + objective). Documented as the required first call for a brand-new agent. - cmd/personal_cockpit.go: paCreate mirrors personalCreate's steps (register in gateway.yaml, open the store, create the objective) and is dispatched BEFORE the paStore existence gate, since the agent doesn't exist yet. pa_resource's grant action now defaults type=filesystem/mode=read when omitted too (same inference the CLI's `resources add` already got), and pa_resource/pa_policy's grant/revoke/stage/approve paths now call WriteConfigMirror — that was wired into the CLI paths last commit but not into the cockpit paths, which are the ones actually used. - cmd/personal.go: every `memcode personal` subcommand is now Hidden — they remain fully callable for scripts, but `memcode personal --help` no longer reads like a CLI to memorize. The interface is `memcode personal` with no args, then plain conversation. - internal/doctrine/prompts.go: the cockpit's own system prompt now says outright never to tell the user to run a CLI command — it does the work itself, right there — and that pa_create, not pa_objective, is the first call for a new agent. Verified end to end: pa_create then a bare-path pa_resource grant (no type, no mode) both succeed through personalExecute exactly as the cockpit would call them, no CLI involved. --- cmd/personal.go | 13 +++++++ cmd/personal_cockpit.go | 64 ++++++++++++++++++++++++++++++-- internal/agent/tools/personal.go | 17 +++++++-- internal/doctrine/prompts.go | 5 ++- 4 files changed, 89 insertions(+), 10 deletions(-) diff --git a/cmd/personal.go b/cmd/personal.go index 3faa8f4..02420c8 100644 --- a/cmd/personal.go +++ b/cmd/personal.go @@ -350,6 +350,19 @@ func init() { answer := &cobra.Command{Use: "answer ", Args: cobra.MinimumNArgs(3), RunE: personalAnswer} deleteCmd := &cobra.Command{Use: "delete ", Args: cobra.ExactArgs(1), RunE: personalDelete} deleteCmd.Flags().Bool("delete-home", false, "also permanently delete the agent home") + // EVERY subcommand here is scripting/automation plumbing — the real + // interface is `memcode personal` with no args, which drops you straight + // into the interactive cockpit and you just say what you want. These stay + // fully callable (a script, a CI job, a habit from muscle memory), but + // Hidden so `memcode personal --help` doesn't read like a CLI to + // memorize — the whole point of the cockpit is that you never need to. + for _, c := range []*cobra.Command{ + create, list, show, run, inbox, answer, pause, resume, stop, deleteCmd, + personalPolicyCmd, personalApprovePolicyCmd, personalResourcesCmd, personalTriggersCmd, + personalHistoryCmd, personalDoctorCmd, personalBrowserCmd, + } { + c.Hidden = true + } personalCmd.AddCommand(create, list, show, run, inbox, answer, pause, resume, stop, personalPolicyCmd, personalApprovePolicyCmd, personalResourcesCmd, personalTriggersCmd, deleteCmd) rootCmd.AddCommand(personalCmd) } diff --git a/cmd/personal_cockpit.go b/cmd/personal_cockpit.go index bbf8434..f4678d7 100644 --- a/cmd/personal_cockpit.go +++ b/cmd/personal_cockpit.go @@ -47,6 +47,7 @@ func personalExecute(ctx context.Context, name string, input json.RawMessage) (s Agent string `json:"agent"` Action string `json:"action"` Text string `json:"text"` + Objective string `json:"objective"` Document string `json:"document"` Hash string `json:"hash"` Type string `json:"type"` @@ -64,6 +65,12 @@ func personalExecute(ctx context.Context, name string, input json.RawMessage) (s if name == tools.PaOverview { return paOverview(ctx) } + if name == tools.PaCreate { + // The agent doesn't exist yet, so it can't go through paStore below + // (which requires it to already be registered) — this is the one + // operation that runs before that gate. + return paCreate(ctx, in.Agent, in.Objective) + } if strings.TrimSpace(in.Agent) == "" { return "", fmt.Errorf("an agent name is required") } @@ -78,7 +85,7 @@ func personalExecute(ctx context.Context, name string, input json.RawMessage) (s case tools.PaPolicy: return paPolicy(ctx, st, home, in.Agent, in.Action, in.Document, in.Hash) case tools.PaResource: - return paResource(ctx, st, in.Action, in.Type, in.Locator, in.Mode, in.ID) + return paResource(ctx, st, home, in.Action, in.Type, in.Locator, in.Mode, in.ID) case tools.PaTrigger: return paTrigger(ctx, st, in.Action, in.Kind, in.Spec, in.ID) case tools.PaWake: @@ -108,7 +115,7 @@ func paOverview(ctx context.Context) (string, error) { } } if len(names) == 0 { - return "No Personal Agents. Create one with `memcode personal create \"\"`.", nil + return "No Personal Agents yet. Ask what the user wants one to do, then call pa_create.", nil } sortStrings(names) for _, n := range names { @@ -134,6 +141,45 @@ func paOverview(ctx context.Context) (string, error) { return b.String(), nil } +// paCreate registers a new Personal Agent and its objective. Mirrors +// personalCreate (the CLI's `personal create`) — same steps, same order — +// since both are legitimate entry points to the same operation; this is the +// one the cockpit conversation actually uses. +func paCreate(ctx context.Context, name, objective string) (string, error) { + name, objective = strings.TrimSpace(name), strings.TrimSpace(objective) + if name == "" || objective == "" { + return "", fmt.Errorf("agent name and objective are both required") + } + s, err := gwconfig.Load() + if err != nil { + return "", err + } + if s.Agents == nil { + s.Agents = map[string]gwconfig.Agent{} + } + if _, ok := s.Agents[name]; ok { + return "", fmt.Errorf("agent %q already exists", name) + } + s.Agents[name] = gwconfig.Agent{Kind: "personal"} + if err := gwconfig.Save(s); err != nil { + return "", err + } + home, err := gwconfig.AgentHome(name) + if err != nil { + return "", err + } + st, err := personal.Open(ctx, home) + if err != nil { + return "", err + } + defer st.Close() + if err := st.CreateObjective(ctx, personal.Objective{ID: "primary", Description: objective, Status: "draft"}); err != nil { + return "", err + } + _ = personal.WriteConfigMirror(ctx, home, st) + return fmt.Sprintf("Created %s. Consequential work is blocked until a policy is staged and approved — gather what it needs (resources, toolsets, consequence classes, wake cadence), present the plan, then pa_policy stage + approve.", name), nil +} + func paObjective(ctx context.Context, st *personal.Store, action, text string) (string, error) { switch strings.ToLower(action) { case "show": @@ -148,7 +194,7 @@ func paObjective(ctx context.Context, st *personal.Store, action, text string) ( return "", err } if !ok { - return "", fmt.Errorf("no primary objective; create the agent with one") + return "", fmt.Errorf("no primary objective — this agent doesn't exist yet; use pa_create, not pa_objective, for a brand-new one") } if err := st.SetObjectiveText(ctx, "primary", text); err != nil { return "", err @@ -188,6 +234,7 @@ func paPolicy(ctx context.Context, st *personal.Store, home, agent, action, docu // Persist the canonical doc to policies/.json (parity with the CLI). _ = os.MkdirAll(filepath.Join(home, "policies"), 0o700) _ = atomicfile.WriteFile(filepath.Join(home, "policies", h+".json"), canon, 0o600) + _ = personal.WriteConfigMirror(ctx, home, st) return fmt.Sprintf("Draft policy v%d staged (hash %s). Approve with pa_policy action=approve hash=%s.", ver, h[:12], h), nil case "approve": pols, err := st.ListPolicies(ctx, "primary") @@ -208,14 +255,21 @@ func paPolicy(ctx context.Context, st *personal.Store, home, agent, action, docu return "", err } _ = st.SetObjectiveStatus(ctx, "primary", "active") + _ = personal.WriteConfigMirror(ctx, home, st) return fmt.Sprintf("Approved policy %s; %s is now active.", match[:12], agent), nil } return "", fmt.Errorf("action must be show, stage, or approve") } -func paResource(ctx context.Context, st *personal.Store, action, rtype, locator, mode, id string) (string, error) { +func paResource(ctx context.Context, st *personal.Store, home, action, rtype, locator, mode, id string) (string, error) { switch strings.ToLower(action) { case "grant": + if rtype == "" { + rtype = "filesystem" // the common case — same inference the CLI's `resources add` uses + } + if mode == "" { + mode = "read" + } if rtype == "filesystem" { canon, err := personal.CanonicalFilesystemGrant(locator) if err != nil { @@ -227,6 +281,7 @@ func paResource(ctx context.Context, st *personal.Store, action, rtype, locator, if err := st.InsertResource(ctx, personal.Resource{ID: rid, ObjectiveID: "primary", Type: rtype, Locator: locator, AccessMode: mode, AuthorizationSource: "cockpit", Status: "active"}); err != nil { return "", err } + _ = personal.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") @@ -245,6 +300,7 @@ func paResource(ctx context.Context, st *personal.Store, action, rtype, locator, if err := st.SetResourceStatus(ctx, id, "revoked"); err != nil { return "", err } + _ = personal.WriteConfigMirror(ctx, home, st) return "revoked " + id, nil } return "", fmt.Errorf("action must be grant, list, or revoke") diff --git a/internal/agent/tools/personal.go b/internal/agent/tools/personal.go index d96806d..8cb0303 100644 --- a/internal/agent/tools/personal.go +++ b/internal/agent/tools/personal.go @@ -7,6 +7,7 @@ import "github.com/memcode-ai/memcode/internal/wire" // objectives, policies, resources, triggers, wakes, and pending interactions. const ( PaOverview = "pa_overview" // list all Personal Agents with status + PaCreate = "pa_create" // create a new Personal Agent (name + objective) PaObjective = "pa_objective" // show/set an agent's objective PaPolicy = "pa_policy" // stage/show/approve delegation policies PaResource = "pa_resource" // grant/list/revoke resources @@ -26,9 +27,17 @@ func PersonalDefs() []wire.ToolDef { Description: "List all Personal Agents: objective, status, approved policy version, pending questions, next wake. Call this first to answer questions about current state.", InputSchema: obj(map[string]any{}), }, + { + Name: PaCreate, + Description: "Create a new Personal Agent: a name and its objective (the durable desired outcome). This is the FIRST call for a brand-new agent — pa_objective/pa_resource/pa_policy all require the agent to already exist. Fails if the name is already taken. The agent starts inert (consequential work blocked) until a policy is staged and approved.", + InputSchema: obj(map[string]any{ + "agent": str("new agent name — short, stable, used everywhere else to refer to it"), + "objective": str("the durable desired outcome, e.g. \"Find and track backend engineering roles, keep a shortlist\""), + }, "agent", "objective"), + }, { Name: PaObjective, - Description: "Show or change an agent's objective (the durable desired outcome + success criteria). action=show or action=set with text.", + Description: "Show or change an EXISTING agent's objective (the durable desired outcome + success criteria). action=show or action=set with text. Use pa_create instead for a brand-new agent.", InputSchema: obj(map[string]any{ "agent": str("agent name"), "action": str("show or set"), @@ -47,13 +56,13 @@ func PersonalDefs() []wire.ToolDef { }, { Name: PaResource, - Description: "Grant or revoke a resource. action=grant (type, locator, mode), action=list, action=revoke (id). Filesystem paths are canonicalized and symlink-resolved.", + Description: "Grant or revoke a resource. action=grant (locator, optionally type and mode), action=list, action=revoke (id). type defaults to filesystem (mode defaults to read) when omitted — the common case is just a path. Filesystem paths are canonicalized and symlink-resolved.", InputSchema: obj(map[string]any{ "agent": str("agent name"), "action": str("grant, list, or revoke"), - "type": str("grant only: filesystem, mcp, command, channel, repository"), + "type": str("grant only: filesystem (default), mcp, command, channel, repository"), "locator": str("grant only: the path or identifier"), - "mode": str("grant only: read, write, or admin"), + "mode": str("grant only: read (default), write, or admin"), "id": str("revoke only: the resource id"), }, "agent", "action"), }, diff --git a/internal/doctrine/prompts.go b/internal/doctrine/prompts.go index bfe77bf..b6b6cab 100644 --- a/internal/doctrine/prompts.go +++ b/internal/doctrine/prompts.go @@ -675,7 +675,8 @@ const personalAdminDoctrine = `You are the memcode Personal Agents cockpit — y You manage: objectives, delegation policies, resource grants, wake triggers, bounded wakes, pending human interactions (questions agents are suspended on), run history, and agent lifecycle. Rules: -- Changes go through the typed pa_* tools, never by hand-editing files: pa_overview, pa_objective, pa_policy, pa_resource, pa_trigger, pa_wake, pa_inbox, pa_answer, pa_history, pa_lifecycle. +- Changes go through the typed pa_* tools, never by hand-editing files, and never by telling the user to run a CLI command — you do the work yourself, right here: pa_overview, pa_create, pa_objective, pa_policy, pa_resource, pa_trigger, pa_wake, pa_inbox, pa_answer, pa_history, pa_lifecycle. (The CLI subcommands under memcode personal exist only for scripts; they are hidden from --help on purpose. Never suggest one to a person you're already talking to — that's you.) +- A brand-new agent starts with pa_create (name + objective), not pa_objective — pa_objective/pa_resource/pa_policy all require the agent to already exist. - A Personal Agent can only do consequential work after a policy is approved. To enable one: pa_policy action=stage with a DelegationPolicy JSON, then pa_policy action=approve with the returned hash. Explain that approval gates authority. - Resources are explicit grants. Use pa_resource to grant a filesystem path (canonicalized, symlink-resolved) with read/write/admin mode; revoke to cut access at the next dispatch. - pa_wake runs one bounded wake now; pa_trigger adds recurring wakes the gateway fires. A wake ends by reporting, scheduling the next wake, or suspending with a question. @@ -718,7 +719,7 @@ else for later. Work it like this, out loud, in the chat: be able to read it and know exactly what authority and what standing schedule they're about to hand over. 3. APPROVE & APPLY: only once the user confirms (adjusting anything they - push back on) do you actually build it, completely — pa_objective, + push back on) do you actually build it, completely — pa_create, then pa_resource grants, pa_policy stage + pa_policy approve for the agreed policy, AND pa_trigger to set up the agreed cadence (or explicitly none, if manual-only was agreed). Don't leave triggers as a "you can add this From 4e9041ef5a9bda7b5e6267fe1c4c4e2dba2cc1fe Mon Sep 17 00:00:00 2001 From: Tim Erwin Date: Sun, 30 Aug 2026 14:10:57 +0700 Subject: [PATCH 06/13] =?UTF-8?q?personal:=20delete=20the=20CLI=20subcomma?= =?UTF-8?q?nd=20surface=20=E2=80=94=20it's=20tools,=20not=20a=20CLI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hiding the subcommands last commit wasn't enough — they still existed as a parallel, scriptable way to do everything, which is real duplicate logic (and already caused drift: a fix landed in one path and not the other, twice, in this same review). Deleted them outright: cmd/personal_resources.go, personal_policy.go, personal_triggers.go, personal_status.go, personal_browser.go, personal_test.go — all removed. cmd/personal.go cut down to just the cockpit launcher (~90 lines from ~370): `memcode personal` opens the interactive session, full stop. Two operations the CLI covered had no pa_* equivalent yet, so they'd have been silently lost: - pa_doctor: the health check (home layout, objective, approved policy, generated workspace, sandbox availability, trigger/pending-interaction counts) that `personal doctor` used to run. - pa_browser_setup: the existing-Chrome prerequisite check + real, bounded chrome-devtools-mcp connection attempt that `personal browser setup` used to run. Same logic, now a tool call instead of a command — it still doesn't (can't) click Chrome's own consent dialog for the user. Both dispatch through personalExecute exactly like every other pa_* tool. internal/agent/tools/personal.go: added PaDoctor and PaBrowserSetup to the registry. cmd/personal_cmd_test.go: rewritten from scratch to call personalExecute directly — no CLI args, no cobra Execute(), no rootCmd. This IS the interface being tested now, so this is what the tests exercise. Also asserts the config-mirror files (policy.yaml, resources.yaml) actually reflect approvals/revokes done through the tool path, since that's the one that matters. Verified: `memcode personal create foo bar` no longer resolves to anything — it just opens the cockpit with those words ignored as stray args, because there is no "create" subcommand left to match. The interface is conversation with pa_* tools behind it, not CLI argument syntax. --- cmd/personal.go | 310 ++----------------------------- cmd/personal_browser.go | 112 ----------- cmd/personal_cmd_test.go | 248 ++++++++++++------------- cmd/personal_cockpit.go | 121 ++++++++++++ cmd/personal_policy.go | 124 ------------- cmd/personal_resources.go | 108 ----------- cmd/personal_status.go | 111 ----------- cmd/personal_test.go | 61 ------ cmd/personal_triggers.go | 93 ---------- internal/agent/tools/personal.go | 36 ++-- 10 files changed, 274 insertions(+), 1050 deletions(-) delete mode 100644 cmd/personal_browser.go delete mode 100644 cmd/personal_policy.go delete mode 100644 cmd/personal_resources.go delete mode 100644 cmd/personal_status.go delete mode 100644 cmd/personal_test.go delete mode 100644 cmd/personal_triggers.go diff --git a/cmd/personal.go b/cmd/personal.go index 02420c8..6903780 100644 --- a/cmd/personal.go +++ b/cmd/personal.go @@ -2,33 +2,35 @@ package cmd import ( "context" - "fmt" "os" "path/filepath" - "sort" - "strings" - "time" "github.com/memcode-ai/memcode/internal/agent/permissions" agentrt "github.com/memcode-ai/memcode/internal/agent/runtime" appconfig "github.com/memcode-ai/memcode/internal/config" - gwconfig "github.com/memcode-ai/memcode/internal/gateway/config" "github.com/memcode-ai/memcode/internal/llm" - "github.com/memcode-ai/memcode/internal/personal" "github.com/memcode-ai/memcode/internal/provider" "github.com/memcode-ai/memcode/internal/store" "github.com/memcode-ai/memcode/internal/vxui" "github.com/spf13/cobra" ) +// personalCmd opens the Personal Agents cockpit. There is no CLI subcommand +// surface here on purpose: every operation (create, list, show, grant a +// resource, stage/approve a policy, add a trigger, run a wake, answer a +// question, check health, pause/stop/delete) is a typed pa_* tool the +// cockpit's own model calls directly (see cmd/personal_cockpit.go and +// internal/agent/tools/personal.go) — you say what you want, in plain +// language, and it does the work. `memcode personal` is the entire interface. var personalCmd = &cobra.Command{ - Use: "personal", Short: "Manage long-lived Personal Agents", + Use: "personal", + Short: "Open the Personal Agents cockpit — an interactive agent that manages your long-lived Personal Agents", Long: `Open the Personal Agents cockpit — an interactive session (like ` + "`memcode admin`" + `) -for managing your long-lived Personal Agents by conversation: objectives, -policies, resources, triggers, wakes, pending questions, and lifecycle. - -Subcommands (run, inbox, answer, policy, resources, triggers, history, doctor, -pause/resume/stop/delete) are the same operations in scriptable form.`, +that manages your long-lived Personal Agents by conversation. Just say what +you want: create one, tell it what files or capabilities it needs, review +and approve its policy, set its wake schedule, check on it, answer a +question it's stuck on, or shut it down — all through talking to it, not +through CLI commands.`, RunE: func(cmd *cobra.Command, args []string) error { return runPersonalCockpit(cmd.Context()) }, @@ -79,290 +81,6 @@ func runPersonalCockpit(ctx context.Context) error { return vxui.Run(ctx, sess, cfg.Theme) } -func personalStore(ctx context.Context, name string) (*personal.Store, error) { - st, _, err := personalStoreHome0(ctx, name) - return st, err -} - -// personalStoreHome returns the open store and the agent's home path. -func personalStoreHome(cmd *cobra.Command, name string) (*personal.Store, string, error) { - return personalStoreHome0(cmd.Context(), name) -} - -func personalStoreHome0(ctx context.Context, name string) (*personal.Store, string, error) { - s, err := gwconfig.Load() - if err != nil { - return nil, "", err - } - a, ok := s.Agents[name] - if !ok || a.Kind != "personal" { - return nil, "", fmt.Errorf("no Personal Agent %q", name) - } - home, err := gwconfig.AgentHome(name) - if err != nil { - return nil, "", err - } - st, err := personal.Open(ctx, home) - if err != nil { - return nil, "", err - } - return st, home, nil -} - -func personalCreate(cmd *cobra.Command, args []string) error { - name, objective := args[0], strings.Join(args[1:], " ") - s, err := gwconfig.Load() - if err != nil { - return err - } - if s.Agents == nil { - s.Agents = map[string]gwconfig.Agent{} - } - if _, ok := s.Agents[name]; ok { - return fmt.Errorf("agent %q already exists", name) - } - s.Agents[name] = gwconfig.Agent{Kind: "personal"} - if err := gwconfig.Save(s); err != nil { - return err - } - home, err := gwconfig.AgentHome(name) - if err != nil { - return err - } - st, err := personal.Open(cmd.Context(), home) - if err != nil { - return err - } - defer st.Close() - if err := st.CreateObjective(cmd.Context(), personal.Objective{ID: "primary", Description: objective, Status: "draft"}); err != nil { - return err - } - // --grant: fold the common "create it, then give it something to read" two - // steps into one. Filesystem only (type is inferred, same as `resources - // add`) — mcp/command/channel grants still need the fuller form, since - // there's no single flag shape that reads naturally for all four. - grants, _ := cmd.Flags().GetStringArray("grant") - for _, g := range grants { - canon, err := personal.CanonicalFilesystemGrant(g) - if err != nil { - return fmt.Errorf("cannot grant %q: %w", g, err) - } - id := fmt.Sprintf("res-filesystem-%d", time.Now().UnixNano()) - if err := st.InsertResource(cmd.Context(), personal.Resource{ - ID: id, ObjectiveID: "primary", Type: "filesystem", Locator: canon, - AccessMode: "read", AuthorizationSource: "user-cli", Status: "active", - }); err != nil { - return fmt.Errorf("granting %q: %w", g, err) - } - } - _ = personal.WriteConfigMirror(cmd.Context(), home, st) - msg := "Created Personal Agent %s" - if len(grants) > 0 { - msg += fmt.Sprintf(" with read access to %s", strings.Join(grants, ", ")) - } - fmt.Fprintf(cmd.OutOrStdout(), msg+". Consequential work remains blocked until its delegation policy is approved.\n", name) - fmt.Fprintf(cmd.OutOrStdout(), "Config: %s\n", home) - return nil -} - -func personalList(cmd *cobra.Command, args []string) error { - s, err := gwconfig.Load() - if err != nil { - return err - } - var names []string - for n, a := range s.Agents { - if a.Kind == "personal" { - names = append(names, n) - } - } - sort.Strings(names) - for _, n := range names { - fmt.Fprintln(cmd.OutOrStdout(), n) - } - return nil -} -func personalShow(cmd *cobra.Command, args []string) error { - st, err := personalStore(cmd.Context(), args[0]) - if err != nil { - return err - } - defer st.Close() - os, err := st.ListObjectives(cmd.Context()) - if err != nil { - return err - } - out := cmd.OutOrStdout() - fmt.Fprintf(out, "Personal Agent: %s\n", args[0]) - for _, o := range os { - fmt.Fprintf(out, "- [%s] %s\n", o.Status, o.Description) - } - return nil -} -func personalStatus(status string) func(*cobra.Command, []string) error { - return func(cmd *cobra.Command, args []string) error { - st, err := personalStore(cmd.Context(), args[0]) - if err != nil { - return err - } - defer st.Close() - if err := st.SetObjectiveStatus(cmd.Context(), "primary", status); err != nil { - return err - } - fmt.Fprintf(cmd.OutOrStdout(), "%s: %s\n", args[0], status) - return nil - } -} - -func personalRun(cmd *cobra.Command, args []string) error { - st, home, err := personalStoreHome(cmd, args[0]) - if err != nil { - return err - } - defer st.Close() - // Fail-closed FIRST: if no approved policy, report blocked before any model - // is constructed, so the operator sees the real blocker (policy, not auth). - if _, hasPol, err := st.ApprovedPolicy(cmd.Context(), "primary"); err != nil { - return err - } else if !hasPol { - fmt.Fprintln(cmd.OutOrStdout(), "blocked: no approved policy — run `memcode personal policy set` then `approve-policy`") - return nil - } - provider.LoadDotEnv() - prov, err := provider.NewFromEnv() - if err != nil { - return fmt.Errorf("no model configured (set MEMCODE_API_TOKEN or an API key): %w", err) - } - ex := &personal.Executive{Store: st, Home: home, AgentID: args[0], Runner: llm.NewRunner(prov)} - out, err := ex.RunOnce(cmd.Context()) - if err != nil { - return err - } - w := cmd.OutOrStdout() - fmt.Fprintf(w, "run %s: %s\n", out.RunID, out.Status) - if out.Report != "" { - fmt.Fprintln(w, out.Report) - } - if out.NextWakeAt != nil { - fmt.Fprintf(w, "next wake: %s\n", out.NextWakeAt.Format(time.RFC3339)) - } - if out.InteractionID != "" { - fmt.Fprintf(w, "suspended: answer with `memcode personal answer %s %s `\n", args[0], out.InteractionID) - } - return nil -} - -func personalInbox(cmd *cobra.Command, args []string) error { - st, _, err := personalStoreHome(cmd, args[0]) - if err != nil { - return err - } - defer st.Close() - inter, err := personal.PendingInteractions(st, args[0]) - if err != nil { - return err - } - w := cmd.OutOrStdout() - if len(inter) == 0 { - fmt.Fprintln(w, "inbox empty — no pending questions") - return nil - } - for _, in := range inter { - fmt.Fprintf(w, "- %s [%s] %s\n", in.ID, in.Kind, in.Question) - } - return nil -} - -func personalAnswer(cmd *cobra.Command, args []string) error { - st, home, err := personalStoreHome(cmd, args[0]) - if err != nil { - return err - } - defer st.Close() - id := args[1] - answer := strings.Join(args[2:], " ") - in, ok, err := personal.GetInteraction(st, id) - if err != nil || !ok { - return fmt.Errorf("no pending interaction %q", id) - } - if in.AgentID != args[0] { - return fmt.Errorf("interaction %q belongs to %s, not %s", id, in.AgentID, args[0]) - } - if in.Status != "pending" { - return fmt.Errorf("interaction %q is not pending (already answered or cancelled) — refusing to re-run its resume", id) - } - // Resume FIRST with the model; only mark the interaction answered after the - // resumed run reaches a terminal state, so a failed resume stays retryable. - provider.LoadDotEnv() - prov, err := provider.NewFromEnv() - if err != nil { - return fmt.Errorf("no model configured: %w", err) - } - ex := &personal.Executive{Store: st, Home: home, AgentID: args[0], Runner: llm.NewRunner(prov)} - out, err := ex.ResumeSuspended(cmd.Context(), in, answer) - if err != nil { - return fmt.Errorf("resume failed (interaction still pending): %w", err) - } - if err := personal.ResolveInteraction(st, id, answer); err != nil { - return err - } - fmt.Fprintf(cmd.OutOrStdout(), "interaction %s answered; run %s → %s\n", id, in.RunID, out.Status) - if out.Report != "" { - fmt.Fprintln(cmd.OutOrStdout(), out.Report) - } - return nil -} -func personalDelete(cmd *cobra.Command, args []string) error { - name := args[0] - destructive, _ := cmd.Flags().GetBool("delete-home") - s, err := gwconfig.Load() - if err != nil { - return err - } - a, ok := s.Agents[name] - if !ok || a.Kind != "personal" { - return fmt.Errorf("no Personal Agent %q", name) - } - delete(s.Agents, name) - if err := gwconfig.Save(s); err != nil { - return err - } - if destructive { - home, _ := gwconfig.AgentHome(name) - if err := os.RemoveAll(home); err != nil { - return err - } - } - fmt.Fprintf(cmd.OutOrStdout(), "Removed %s from gateway configuration; home deleted=%v.\n", name, destructive) - return nil -} - func init() { - create := &cobra.Command{Use: "create ", Args: cobra.MinimumNArgs(2), RunE: personalCreate} - create.Flags().StringArray("grant", nil, "grant read access to a file or directory (repeatable), e.g. --grant ~/resume.md") - list := &cobra.Command{Use: "list", Args: cobra.NoArgs, RunE: personalList} - show := &cobra.Command{Use: "show ", Args: cobra.ExactArgs(1), RunE: personalShow} - pause := &cobra.Command{Use: "pause ", Args: cobra.ExactArgs(1), RunE: personalStatus("paused")} - resume := &cobra.Command{Use: "resume ", Args: cobra.ExactArgs(1), RunE: personalStatus("active")} - stop := &cobra.Command{Use: "stop ", Args: cobra.ExactArgs(1), RunE: personalStatus("stopped")} - run := &cobra.Command{Use: "run ", Args: cobra.ExactArgs(1), RunE: personalRun} - inbox := &cobra.Command{Use: "inbox ", Args: cobra.ExactArgs(1), RunE: personalInbox} - answer := &cobra.Command{Use: "answer ", Args: cobra.MinimumNArgs(3), RunE: personalAnswer} - deleteCmd := &cobra.Command{Use: "delete ", Args: cobra.ExactArgs(1), RunE: personalDelete} - deleteCmd.Flags().Bool("delete-home", false, "also permanently delete the agent home") - // EVERY subcommand here is scripting/automation plumbing — the real - // interface is `memcode personal` with no args, which drops you straight - // into the interactive cockpit and you just say what you want. These stay - // fully callable (a script, a CI job, a habit from muscle memory), but - // Hidden so `memcode personal --help` doesn't read like a CLI to - // memorize — the whole point of the cockpit is that you never need to. - for _, c := range []*cobra.Command{ - create, list, show, run, inbox, answer, pause, resume, stop, deleteCmd, - personalPolicyCmd, personalApprovePolicyCmd, personalResourcesCmd, personalTriggersCmd, - personalHistoryCmd, personalDoctorCmd, personalBrowserCmd, - } { - c.Hidden = true - } - personalCmd.AddCommand(create, list, show, run, inbox, answer, pause, resume, stop, personalPolicyCmd, personalApprovePolicyCmd, personalResourcesCmd, personalTriggersCmd, deleteCmd) rootCmd.AddCommand(personalCmd) } diff --git a/cmd/personal_browser.go b/cmd/personal_browser.go deleted file mode 100644 index 94a2376..0000000 --- a/cmd/personal_browser.go +++ /dev/null @@ -1,112 +0,0 @@ -package cmd - -import ( - "context" - "fmt" - "os/exec" - "time" - - "github.com/memcode-ai/memcode/internal/browser" - "github.com/memcode-ai/memcode/internal/browser/broker" - "github.com/memcode-ai/memcode/internal/mcp" - "github.com/spf13/cobra" -) - -// personalBrowserCmd groups existing-Chrome setup/diagnostics under -// `memcode personal browser`. Personal Agents default their "browser" -// toolset to the user's OWN already-running Chrome (see -// docs/design/personal-agents.md "Browser broker trust boundary"), not a -// fresh ephemeral profile — this is where that gets configured and verified. -var personalBrowserCmd = &cobra.Command{Use: "browser", Short: "Set up and check existing-Chrome access for delegated Personal Agent workers"} - -var personalBrowserSetupCmd = &cobra.Command{ - Use: "setup", - Short: "Check prerequisites and connect to your already-running Chrome", - Long: `Personal Agents delegate browser work to your OWN already-running, already- -logged-in Chrome — not a fresh profile — so a delegated worker can actually -use accounts you're signed into (Gmail, LinkedIn, an ATS, ...). This requires: - - 1. Chrome 144+. - 2. Remote Debugging enabled: open chrome://inspect/#remote-debugging in - Chrome and toggle Remote Debugging on. - 3. The memcode gateway running (` + "`memcode gateway run`" + `) — it owns the - broker that arbitrates which delegated worker may drive Chrome at a - time, so at most one worker touches it at once. - -This command checks each prerequisite and attempts a real connection. It -does NOT click Chrome's own "Allow" dialog for you — the first connection -attempt after this shows that dialog in Chrome itself, and only you can -approve it. If anything here fails, existing-Chrome delegation fails closed -rather than silently falling back to a fresh, logged-out browser.`, - RunE: func(cmd *cobra.Command, args []string) error { - w := cmd.OutOrStdout() - ok := true - check := func(name string, good bool, detail string) { - mark := "ok" - if !good { - mark = "FAIL" - ok = false - } - fmt.Fprintf(w, " [%s] %s: %s\n", mark, name, detail) - } - - npx, err := exec.LookPath("npx") - check("npx available", err == nil, func() string { - if err != nil { - return "not found on PATH — Node.js is required" - } - return npx - }()) - - 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, func() string { - if reachable { - return sock - } - return "not reachable — start `memcode gateway run` first" - }()) - } - - if !ok { - fmt.Fprintln(w, "\nFix the above, then re-run `memcode personal browser setup`.") - return fmt.Errorf("prerequisites not met") - } - - fmt.Fprintln(w, "\nAttempting a connection to your running Chrome (10s timeout)...") - fmt.Fprintln(w, "If Chrome shows an \"Allow\" dialog, click Allow — that's Chrome's own consent") - fmt.Fprintln(w, "step, not something this command can do for you.") - ctx, cancel := context.WithTimeout(cmd.Context(), 10*time.Second) - defer cancel() - mgr := mcp.Connect(ctx, map[string]mcp.ServerConfig{ - "chrome-devtools": {Type: "stdio", Command: "npx", Args: []string{"-y", browser.ChromeDevToolsMCPPackage, "--autoConnect"}}, - }, mcp.Options{Version: mcpSetupClientVersion}) - defer mgr.Close() - tools := mgr.Tools() - errs := mgr.Errors() - if len(tools) == 0 { - fmt.Fprintln(w, "\n [FAIL] could not connect to Chrome") - for _, e := range errs { - fmt.Fprintf(w, " - %v\n", e) - } - fmt.Fprintln(w, "\nCheck: Chrome 144+, chrome://inspect/#remote-debugging toggled on, Chrome") - fmt.Fprintln(w, "actually running (autoConnect attaches to a running instance, it doesn't") - fmt.Fprintln(w, "launch one), and that you clicked Allow if a dialog appeared.") - return fmt.Errorf("existing-Chrome connection failed") - } - fmt.Fprintf(w, "\n [ok] connected — %d browser tool(s) available\n", len(tools)) - fmt.Fprintln(w, "\nExisting-Chrome delegation is ready. A Personal Agent's delegate calls with") - fmt.Fprintln(w, "toolsets:[\"browser\"] will now use this session by default.") - return nil - }, -} - -const mcpSetupClientVersion = "0.1.0" - -func init() { - personalBrowserCmd.AddCommand(personalBrowserSetupCmd) - personalCmd.AddCommand(personalBrowserCmd) -} diff --git a/cmd/personal_cmd_test.go b/cmd/personal_cmd_test.go index b4eeec3..e8c3f0c 100644 --- a/cmd/personal_cmd_test.go +++ b/cmd/personal_cmd_test.go @@ -1,7 +1,6 @@ package cmd import ( - "bytes" "context" "encoding/json" "os" @@ -13,18 +12,8 @@ import ( gwconfig "github.com/memcode-ai/memcode/internal/gateway/config" ) -// execPersonal runs a personal subcommand with an isolated HOME + config. -func execPersonal(t *testing.T, args ...string) (string, error) { - t.Helper() - cmd := rootCmd - var out bytes.Buffer - cmd.SetOut(&out) - cmd.SetErr(&out) - cmd.SetArgs(args) - err := cmd.Execute() - return out.String(), err -} - +// setupPersonalHome isolates HOME/XDG_CONFIG_HOME so a test's Personal Agents +// never touch the real ~/.memcode or ~/.config/memcode. func setupPersonalHome(t *testing.T) string { t.Helper() home := t.TempDir() @@ -33,174 +22,165 @@ func setupPersonalHome(t *testing.T) string { return home } -func TestPolicyLifecycleBlocksThenApproves(t *testing.T) { - setupPersonalHome(t) - if _, err := execPersonal(t, "personal", "create", "pa", "Keep things tidy"); err != nil { +// call runs one pa_* tool exactly as the cockpit would — this IS the +// interface now (see cmd/personal.go): there is no CLI subcommand path to +// fall back to, so every test here goes through personalExecute. +func call(t *testing.T, name string, in map[string]any) string { + t.Helper() + b, err := json.Marshal(in) + if err != nil { t.Fatal(err) } - // No policy yet → run is blocked (and must not call a model). - out, err := execPersonal(t, "personal", "run", "pa") - // run errors only because no model is configured in test env; that's fine — - // what we assert is the policy gate happens BEFORE any model requirement. - _ = out - _ = err - // Stage + approve a policy. - dir := t.TempDir() - pfile := filepath.Join(dir, "policy.json") - 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, + out, err := personalExecute(context.Background(), name, b) + if err != nil { + t.Fatalf("%s(%v): %v", name, in, err) } - b, _ := json.Marshal(policy) - if err := os.WriteFile(pfile, b, 0o600); err != nil { + return out +} + +func TestPaCreateListPauseResumeStopDelete(t *testing.T) { + home := setupPersonalHome(t) + call(t, tools.PaCreate, map[string]any{"agent": "test-agent", "objective": "Maintain an arbitrary outcome"}) + + cfg, err := gwconfig.Load() + if err != nil { t.Fatal(err) } - out, err = execPersonal(t, "personal", "policy", "set", "pa", pfile) - if err != nil { + if cfg.Agents["test-agent"].Kind != "personal" { + t.Fatalf("agent=%+v", cfg.Agents["test-agent"]) + } + if _, err := os.Stat(filepath.Join(home, ".memcode", "agents", "test-agent", "personal.db")); err != nil { t.Fatal(err) } + + call(t, tools.PaLifecycle, map[string]any{"agent": "test-agent", "action": "pause"}) + call(t, tools.PaLifecycle, map[string]any{"agent": "test-agent", "action": "resume"}) + call(t, tools.PaLifecycle, map[string]any{"agent": "test-agent", "action": "stop"}) + + out := call(t, tools.PaOverview, map[string]any{}) + if !strings.Contains(out, "test-agent") { + t.Fatalf("overview missing agent: %q", out) + } + + call(t, tools.PaLifecycle, map[string]any{"agent": "test-agent", "action": "delete"}) + if _, err := os.Stat(filepath.Join(home, ".memcode", "agents", "test-agent")); err != nil { + t.Fatal("non-destructive delete removed home") + } +} + +func TestPaPolicyLifecycleBlocksThenApproves(t *testing.T) { + setupPersonalHome(t) + call(t, tools.PaCreate, map[string]any{"agent": "pa", "objective": "Keep things tidy"}) + + // No policy yet → wake is blocked. + out := call(t, tools.PaWake, map[string]any{"agent": "pa"}) + if !strings.Contains(out, "blocked") { + t.Fatalf("expected blocked wake, 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 = call(t, tools.PaPolicy, map[string]any{"agent": "pa", "action": "stage", "document": string(pb)}) if !strings.Contains(out, "Draft policy v1 staged") { - t.Fatalf("set=%q", out) + t.Fatalf("stage=%q", out) } - // Extract hash from the policy file name written under the agent home. home, _ := gwconfig.AgentHome("pa") entries, err := os.ReadDir(filepath.Join(home, "policies")) if err != nil || len(entries) != 1 { t.Fatalf("policies dir: %v %v", entries, err) } hash := strings.TrimSuffix(entries[0].Name(), ".json") - // Approve by prefix. - out, err = execPersonal(t, "personal", "approve-policy", "pa", hash[:12]) - if err != nil { - t.Fatal(err) - } + + out = call(t, tools.PaPolicy, map[string]any{"agent": "pa", "action": "approve", "hash": hash[:12]}) if !strings.Contains(out, "Approved policy") { t.Fatalf("approve=%q", out) } - // Show reports approved. - out, err = execPersonal(t, "personal", "policy", "show", "pa") - if err != nil { - t.Fatal(err) - } + out = call(t, tools.PaPolicy, map[string]any{"agent": "pa", "action": "show"}) if !strings.Contains(out, "approved policy v1") { t.Fatalf("show=%q", out) } + + // Config mirror actually reflects the approved policy. + mirrored, err := os.ReadFile(filepath.Join(home, "policy.yaml")) + if err != nil || !strings.Contains(string(mirrored), "approved: true") { + t.Fatalf("policy.yaml mirror missing approval: %v %q", err, mirrored) + } } -func TestResourcesAndTriggersCommands(t *testing.T) { +func TestPaResourceAndTrigger(t *testing.T) { home := setupPersonalHome(t) - if _, err := execPersonal(t, "personal", "create", "pa2", "Watch a folder"); err != nil { - t.Fatal(err) - } + call(t, tools.PaCreate, map[string]any{"agent": "pa2", "objective": "Watch a folder"}) + grant := filepath.Join(home, "watch") if err := os.MkdirAll(grant, 0o755); err != nil { t.Fatal(err) } - out, err := execPersonal(t, "personal", "resources", "add", "pa2", "filesystem", grant, "--mode", "write") - if err != nil { - t.Fatal(err) + // No type, no mode — the common case a person would actually ask for. + out := call(t, tools.PaResource, map[string]any{"agent": "pa2", "action": "grant", "locator": grant}) + if !strings.Contains(out, "Granted filesystem") || !strings.Contains(out, "(read)") { + t.Fatalf("grant=%q", out) } - if !strings.Contains(out, "Granted filesystem") { - t.Fatalf("add=%q", out) - } - out, err = execPersonal(t, "personal", "resources", "list", "pa2") - if err != nil || !strings.Contains(out, "filesystem") { - t.Fatalf("list=%q err=%v", out, err) - } - // Trigger add/list. - out, err = execPersonal(t, "personal", "triggers", "add", "pa2", "interval", "30m") - if err != nil { - t.Fatal(err) + out = call(t, tools.PaResource, map[string]any{"agent": "pa2", "action": "list"}) + if !strings.Contains(out, "filesystem") { + t.Fatalf("list=%q", out) } + + out = call(t, tools.PaTrigger, map[string]any{"agent": "pa2", "action": "add", "kind": "interval", "spec": "30m"}) if !strings.Contains(out, "next wake") { t.Fatalf("trigger add=%q", out) } - out, err = execPersonal(t, "personal", "triggers", "list", "pa2") - if err != nil || !strings.Contains(out, "interval") { - t.Fatalf("triggers list=%q err=%v", out, err) - } - // Revoke a resource by parsing its id (field after "- ", before ":"). - out, err = execPersonal(t, "personal", "resources", "list", "pa2") - if err != nil { - t.Fatal(err) + out = call(t, tools.PaTrigger, map[string]any{"agent": "pa2", "action": "list"}) + if !strings.Contains(out, "interval") { + t.Fatalf("trigger list=%q", out) } + + // Revoke by parsing the id out of the list output. + out = call(t, tools.PaResource, map[string]any{"agent": "pa2", "action": "list"}) var resID string for _, line := range strings.Split(out, "\n") { - if strings.HasPrefix(line, "- ") { - rest := strings.TrimPrefix(line, "- ") - if i := strings.Index(rest, ":"); i > 0 { - resID = rest[:i] - } + if i := strings.Index(line, ":"); i > 0 { + resID = line[:i] + break } } if resID == "" { t.Fatalf("no resource id in %q", out) } - if _, err := execPersonal(t, "personal", "resources", "revoke", "pa2", resID); err != nil { - t.Fatal(err) - } - // Confirm revoked. - out, _ = execPersonal(t, "personal", "resources", "list", "pa2") + call(t, tools.PaResource, map[string]any{"agent": "pa2", "action": "revoke", "id": resID}) + out = call(t, tools.PaResource, map[string]any{"agent": "pa2", "action": "list"}) if !strings.Contains(out, "[revoked]") { t.Fatalf("expected revoked: %q", out) } + + // resources.yaml mirror reflects the revoke. + agentHome, _ := gwconfig.AgentHome("pa2") + mirrored, err := os.ReadFile(filepath.Join(agentHome, "resources.yaml")) + if err != nil || !strings.Contains(string(mirrored), "revoked") { + t.Fatalf("resources.yaml mirror missing revoke: %v %q", err, mirrored) + } } -// The cockpit executor drives the same operations the subcommands expose, via -// typed pa_* tool calls. Read-only calls return state; mutations mutate. -func TestPersonalCockpitExecutor(t *testing.T) { +func TestPaDoctorAndOverview(t *testing.T) { setupPersonalHome(t) - ctx := context.Background() - if _, err := execPersonal(t, "personal", "create", "cock", "Tidy my notes"); err != nil { - t.Fatal(err) + call(t, tools.PaCreate, map[string]any{"agent": "cock", "objective": "Tidy my notes"}) + + out := call(t, tools.PaOverview, map[string]any{}) + if !strings.Contains(out, "cock") { + t.Fatalf("overview=%q", out) } - // Overview (read). - out, err := personalExecute(ctx, tools.PaOverview, json.RawMessage(`{}`)) - if err != nil || !strings.Contains(out, "cock") { - t.Fatalf("overview=%q err=%v", out, err) - } - // Objective show (read). - out, err = personalExecute(ctx, tools.PaObjective, json.RawMessage(`{"agent":"cock","action":"show"}`)) - if err != nil || !strings.Contains(out, "Tidy my notes") { - t.Fatalf("objective=%q err=%v", out, err) - } - // Policy stage + approve via cockpit. - pol := map[string]any{"objective_scope": "primary", "consequence_classes": []string{"observe"}, "max_seconds": 60, "max_actions_per_period": 4} - pb, _ := json.Marshal(pol) - docJSON, _ := json.Marshal(map[string]string{"agent": "cock", "action": "stage", "document": string(pb)}) - out, err = personalExecute(ctx, tools.PaPolicy, docJSON) - if err != nil || !strings.Contains(out, "Draft policy v1 staged") { - t.Fatalf("stage=%q err=%v", out, err) - } - home, _ := gwconfig.AgentHome("cock") - entries, _ := os.ReadDir(filepath.Join(home, "policies")) - hash := strings.TrimSuffix(entries[0].Name(), ".json") - apJSON, _ := json.Marshal(map[string]string{"agent": "cock", "action": "approve", "hash": hash}) - out, err = personalExecute(ctx, tools.PaPolicy, apJSON) - if err != nil || !strings.Contains(out, "Approved policy") { - t.Fatalf("approve=%q err=%v", out, err) - } - // Trigger add + list via cockpit. - trJSON, _ := json.Marshal(map[string]string{"agent": "cock", "action": "add", "kind": "interval", "spec": "15m"}) - out, err = personalExecute(ctx, tools.PaTrigger, trJSON) - if err != nil || !strings.Contains(out, "next wake") { - t.Fatalf("trigger add=%q err=%v", out, err) - } - tlJSON, _ := json.Marshal(map[string]string{"agent": "cock", "action": "list"}) - out, err = personalExecute(ctx, tools.PaTrigger, tlJSON) - if err != nil || !strings.Contains(out, "interval") { - t.Fatalf("trigger list=%q err=%v", out, err) - } - // Inbox (read) and lifecycle pause. - inJSON, _ := json.Marshal(map[string]string{"agent": "cock"}) - out, err = personalExecute(ctx, tools.PaInbox, inJSON) - if err != nil || !strings.Contains(out, "inbox empty") { - t.Fatalf("inbox=%q err=%v", out, err) - } - lcJSON, _ := json.Marshal(map[string]string{"agent": "cock", "action": "pause"}) - out, err = personalExecute(ctx, tools.PaLifecycle, lcJSON) - if err != nil || !strings.Contains(out, "paused") { - t.Fatalf("pause=%q err=%v", out, err) + out = call(t, tools.PaObjective, map[string]any{"agent": "cock", "action": "show"}) + if !strings.Contains(out, "Tidy my notes") { + t.Fatalf("objective=%q", out) + } + out = call(t, tools.PaDoctor, map[string]any{"agent": "cock"}) + if !strings.Contains(out, "objective") || !strings.Contains(out, "sandbox") { + t.Fatalf("doctor=%q", out) + } + out = call(t, tools.PaInbox, map[string]any{"agent": "cock"}) + if !strings.Contains(out, "inbox empty") { + t.Fatalf("inbox=%q", out) } } diff --git a/cmd/personal_cockpit.go b/cmd/personal_cockpit.go index f4678d7..2bc95f7 100644 --- a/cmd/personal_cockpit.go +++ b/cmd/personal_cockpit.go @@ -10,14 +10,18 @@ import ( "encoding/json" "fmt" "os" + "os/exec" "path/filepath" "strings" "time" "github.com/memcode-ai/memcode/internal/agent/tools" "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/personal" "github.com/memcode-ai/memcode/internal/provider" ) @@ -71,6 +75,9 @@ func personalExecute(ctx context.Context, name string, input json.RawMessage) (s // operation that runs before that gate. return paCreate(ctx, in.Agent, in.Objective) } + if name == tools.PaBrowserSetup { + return paBrowserSetup(ctx) // agent-independent: gateway-wide broker/Chrome setup + } if strings.TrimSpace(in.Agent) == "" { return "", fmt.Errorf("an agent name is required") } @@ -98,6 +105,8 @@ func personalExecute(ctx context.Context, name string, input json.RawMessage) (s return paHistory(ctx, st) case tools.PaLifecycle: return paLifecycle(ctx, in.Agent, in.Action, in.DeleteHome) + case tools.PaDoctor: + return paDoctor(ctx, st, home, in.Agent) } return "", fmt.Errorf("unknown personal tool %q", name) } @@ -482,3 +491,115 @@ func sortStrings(s []string) { } } } + +func shortHash(h string) string { + if len(h) > 12 { + return h[:12] + } + return h +} + +func sandboxNote() string { + if personal.SandboxAvailable() { + return "hardened (bwrap)" + } + return "no bwrap — generated code runs fail-closed unless explicitly approved" +} + +// paDoctor health-checks one agent: home layout, objective, approved policy, +// generated workspace, sandbox availability, trigger/pending-interaction +// counts. Returns a report string rather than a bool — the cockpit relays +// findings to the user itself, it doesn't need a separate pass/fail signal. +func paDoctor(ctx context.Context, st *personal.Store, home, agent string) (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)) + } + obj, hasObj, _ := st.GetObjective(ctx, "primary") + check("objective", hasObj, obj.Description) + 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 := personal.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()) + trigs, _ := st.ListTriggers(ctx) + pend, _ := st.PendingInteractions(ctx, agent) + fmt.Fprintf(&b, "triggers: %d, pending interactions: %d\n", len(trigs), len(pend)) + return b.String(), nil +} + +// paBrowserSetup checks existing-Chrome delegation prerequisites and attempts +// a real, bounded connection — the same checks `memcode personal browser +// setup` used to run as a separate CLI command, now reachable the same way +// every other Personal Agent operation is: a tool call in the cockpit +// conversation, not a command the user has to know to type. +func paBrowserSetup(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, func() string { + if err != nil { + return "not found on PATH — Node.js is required" + } + return npx + }()) + 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, func() string { + if reachable { + return sock + } + return "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 := mcp.Connect(cctx, map[string]mcp.ServerConfig{ + "chrome-devtools": {Type: "stdio", Command: "npx", Args: []string{"-y", browser.ChromeDevToolsMCPPackage, "--autoConnect"}}, + }, mcp.Options{Version: "0.1.0"}) + 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: Chrome 144+, chrome://inspect/#remote-debugging toggled on, Chrome\n") + b.WriteString("actually running, and click Allow if a dialog appears in Chrome — only they can.\n") + return b.String(), nil + } + fmt.Fprintf(&b, "[ok] connected — %d browser tool(s) available. Existing-Chrome delegation is ready.\n", toolCount) + return b.String(), nil +} diff --git a/cmd/personal_policy.go b/cmd/personal_policy.go deleted file mode 100644 index 41dbefc..0000000 --- a/cmd/personal_policy.go +++ /dev/null @@ -1,124 +0,0 @@ -package cmd - -import ( - "encoding/json" - "fmt" - "os" - "strings" - - "github.com/memcode-ai/memcode/internal/atomicfile" - "github.com/memcode-ai/memcode/internal/personal" - "github.com/spf13/cobra" -) - -var personalPolicyCmd = &cobra.Command{Use: "policy", Short: "Manage delegation policies"} - -var personalPolicySetCmd = &cobra.Command{ - Use: "set ", Args: cobra.ExactArgs(2), - Short: "Stage a new draft policy (JSON) for review", - RunE: func(cmd *cobra.Command, args []string) error { - st, home, err := personalStoreHome(cmd, args[0]) - if err != nil { - return err - } - defer st.Close() - raw, err := os.ReadFile(args[1]) - if err != nil { - return err - } - var doc personal.DelegationPolicy - if err := json.Unmarshal(raw, &doc); err != nil { - return fmt.Errorf("policy is not valid DelegationPolicy JSON: %w", err) - } - canon, hash, err := personal.CanonicalPolicy(doc) - if err != nil { - return err - } - ver, err := st.NextPolicyVersion(cmd.Context(), "primary") - if err != nil { - return err - } - p := personal.Policy{ID: "policy-" + hash[:8], ObjectiveID: "primary", Version: ver, Document: canon, Hash: hash, Status: "draft"} - if err := st.InsertPolicy(cmd.Context(), p); err != nil { - return err - } - path := home + "/policies/" + hash + ".json" - if err := atomicfile.WriteFile(path, canon, 0o600); err != nil { - return err - } - _ = personal.WriteConfigMirror(cmd.Context(), home, st) - fmt.Fprintf(cmd.OutOrStdout(), "Draft policy v%d staged (hash %s…). Review with `personal policy show %s` then approve with `personal approve-policy %s %s`.\n", ver, hash[:12], args[0], args[0], hash) - return nil - }, -} - -var personalPolicyShowCmd = &cobra.Command{ - Use: "show [hash]", Args: cobra.RangeArgs(1, 2), - Short: "Show the approved policy (or a specific one by hash)", - RunE: func(cmd *cobra.Command, args []string) error { - st, _, err := personalStoreHome(cmd, args[0]) - if err != nil { - return err - } - defer st.Close() - if len(args) == 2 { - pols, err := st.ListPolicies(cmd.Context(), "primary") - if err != nil { - return err - } - for _, p := range pols { - if p.Hash == args[1] || strings.HasPrefix(p.Hash, args[1]) { - fmt.Fprintf(cmd.OutOrStdout(), "policy v%d [%s] hash=%s\n%s\n", p.Version, p.Status, p.Hash, string(p.Document)) - return nil - } - } - return fmt.Errorf("no policy matching %q", args[1]) - } - p, ok, err := st.ApprovedPolicy(cmd.Context(), "primary") - if err != nil { - return err - } - if !ok { - fmt.Fprintln(cmd.OutOrStdout(), "no approved policy — consequential work is blocked") - return nil - } - fmt.Fprintf(cmd.OutOrStdout(), "approved policy v%d hash=%s approved_at=%s\n%s\n", p.Version, p.Hash, p.ApprovedAt, string(p.Document)) - return nil - }, -} - -var personalApprovePolicyCmd = &cobra.Command{ - Use: "approve-policy ", Args: cobra.ExactArgs(2), - Short: "Approve a staged draft policy by its hash", - RunE: func(cmd *cobra.Command, args []string) error { - st, home, err := personalStoreHome(cmd, args[0]) - if err != nil { - return err - } - defer st.Close() - pols, err := st.ListPolicies(cmd.Context(), "primary") - if err != nil { - return err - } - var match string - for _, p := range pols { - if p.Hash == args[1] || strings.HasPrefix(p.Hash, args[1]) { - match = p.Hash - break - } - } - if match == "" { - return fmt.Errorf("no policy matching %q", args[1]) - } - if err := st.ApprovePolicy(cmd.Context(), match); err != nil { - return err - } - // Move objective out of draft so scheduled/manual wakes may run. - _ = st.SetObjectiveStatus(cmd.Context(), "primary", "active") - _ = personal.WriteConfigMirror(cmd.Context(), home, st) - fmt.Fprintf(cmd.OutOrStdout(), "Approved policy %s… for %s; objective is now active.\n", match[:12], args[0]) - return nil - }, -} - -func init() { personalPolicyCmd.AddCommand(personalPolicySetCmd, personalPolicyShowCmd) } diff --git a/cmd/personal_resources.go b/cmd/personal_resources.go deleted file mode 100644 index 4ac30be..0000000 --- a/cmd/personal_resources.go +++ /dev/null @@ -1,108 +0,0 @@ -package cmd - -import ( - "fmt" - "time" - - "github.com/memcode-ai/memcode/internal/personal" - "github.com/spf13/cobra" -) - -var personalResourcesCmd = &cobra.Command{Use: "resources", Short: "Manage resource grants"} - -var personalResourcesAddCmd = &cobra.Command{ - Use: "add [type] ", Args: cobra.RangeArgs(2, 3), - Short: "Grant a resource (filesystem path, mcp tool, command, channel)", - Long: `Grant a resource to a Personal Agent. - -For a filesystem path, type is optional and inferred — a bare path is enough: - - memcode personal resources add jobhunt ~/resume.md - -Non-filesystem grants (mcp, command, channel) need the type spelled out: - - memcode personal resources add jobhunt mcp gmail`, - RunE: func(cmd *cobra.Command, args []string) error { - st, home, err := personalStoreHome(cmd, args[0]) - if err != nil { - return err - } - defer st.Close() - mode, _ := cmd.Flags().GetString("mode") - // Two positional args (agent, locator): type is inferred as filesystem - // when the locator actually resolves to a real path on disk — that's - // the common case (grant a file/dir), and it fails loudly rather than - // guessing when it doesn't resolve. Three args names the type - // explicitly, required for mcp/command/channel (nothing on disk to - // resolve against). - var rtype, locator string - if len(args) == 2 { - rtype, locator = "filesystem", args[1] - } else { - rtype, locator = args[1], args[2] - } - if rtype == "filesystem" { - canon, err := personal.CanonicalFilesystemGrant(locator) - if err != nil { - return fmt.Errorf("cannot grant filesystem path: %w", err) - } - locator = canon - } - id := fmt.Sprintf("res-%s-%d", rtype, time.Now().UnixNano()) - if err := st.InsertResource(cmd.Context(), personal.Resource{ - ID: id, ObjectiveID: "primary", Type: rtype, Locator: locator, - AccessMode: mode, AuthorizationSource: "user-cli", Status: "active", - }); err != nil { - return err - } - _ = personal.WriteConfigMirror(cmd.Context(), home, st) - fmt.Fprintf(cmd.OutOrStdout(), "Granted %s %s (%s) to %s.\n", rtype, locator, mode, args[0]) - return nil - }, -} - -var personalResourcesListCmd = &cobra.Command{ - Use: "list ", Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - st, _, err := personalStoreHome(cmd, args[0]) - if err != nil { - return err - } - defer st.Close() - res, err := st.ListResources(cmd.Context(), "primary") - if err != nil { - return err - } - out := cmd.OutOrStdout() - if len(res) == 0 { - fmt.Fprintln(out, "no resource grants — the agent can only use its own home") - return nil - } - for _, r := range res { - fmt.Fprintf(out, "- %s: %s %s (%s) [%s]\n", r.ID, r.Type, r.Locator, r.AccessMode, r.Status) - } - return nil - }, -} - -var personalResourcesRevokeCmd = &cobra.Command{ - Use: "revoke ", Args: cobra.ExactArgs(2), - RunE: func(cmd *cobra.Command, args []string) error { - st, home, err := personalStoreHome(cmd, args[0]) - if err != nil { - return err - } - defer st.Close() - if err := st.SetResourceStatus(cmd.Context(), args[1], "revoked"); err != nil { - return err - } - _ = personal.WriteConfigMirror(cmd.Context(), home, st) - fmt.Fprintf(cmd.OutOrStdout(), "Revoked %s on %s (effective at the next dispatch).\n", args[1], args[0]) - return nil - }, -} - -func init() { - personalResourcesAddCmd.Flags().String("mode", "read", "access mode: read, write, or admin") - personalResourcesCmd.AddCommand(personalResourcesAddCmd, personalResourcesListCmd, personalResourcesRevokeCmd) -} diff --git a/cmd/personal_status.go b/cmd/personal_status.go deleted file mode 100644 index ccecc66..0000000 --- a/cmd/personal_status.go +++ /dev/null @@ -1,111 +0,0 @@ -package cmd - -import ( - "fmt" - "os" - "path/filepath" - "time" - - "github.com/memcode-ai/memcode/internal/personal" - "github.com/spf13/cobra" -) - -var personalHistoryCmd = &cobra.Command{ - Use: "history ", Args: cobra.ExactArgs(1), - Short: "Show recent runs and journaled actions", - RunE: func(cmd *cobra.Command, args []string) error { - st, _, err := personalStoreHome(cmd, args[0]) - if err != nil { - return err - } - defer st.Close() - ctx := cmd.Context() - w := cmd.OutOrStdout() - runs, err := st.ListRuns(ctx, "primary", 10) - if err != nil { - return err - } - fmt.Fprintf(w, "RUNS (%d most recent):\n", len(runs)) - for _, r := range runs { - fmt.Fprintf(w, " %s [%s] %s\n", r.ID, r.Status, r.CreatedAt.Format(time.RFC3339)) - } - actions, err := st.ListActions(ctx, "primary", 20) - if err != nil { - return err - } - fmt.Fprintf(w, "ACTIONS (%d most recent):\n", len(actions)) - for _, a := range actions { - fmt.Fprintf(w, " %s %s %s → %s (policy %s)\n", a.CreatedAt.Format("15:04:05"), a.Kind, a.Target, a.Status, shortHash(a.PolicyHash)) - } - return nil - }, -} - -var personalDoctorCmd = &cobra.Command{ - Use: "doctor ", Args: cobra.ExactArgs(1), - Short: "Check a Personal Agent's home, policy, and runtime health", - RunE: func(cmd *cobra.Command, args []string) error { - st, home, err := personalStoreHome(cmd, args[0]) - if err != nil { - return err - } - defer st.Close() - ctx := cmd.Context() - w := cmd.OutOrStdout() - ok := true - check := func(name string, good bool, detail string) { - mark := "ok" - if !good { - mark = "FAIL" - ok = false - } - fmt.Fprintf(w, " [%s] %s: %s\n", mark, name, 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)) - } - obj, hasObj, _ := st.GetObjective(ctx, "primary") - check("objective", hasObj, obj.Description) - pol, hasPol, _ := st.ApprovedPolicy(ctx, "primary") - check("approved policy", hasPol, func() string { - if hasPol { - return fmt.Sprintf("v%d", pol.Version) + " " + shortHash(pol.Hash) - } - return "none — consequential work blocked" - }()) - if _, err := personal.InitializeGeneratedWorkspace(home); err != nil { - check("generated workspace", false, err.Error()) - } else { - check("generated workspace", true, "git initialized") - } - // Sandbox availability is informational, not a failure: on platforms without - // bwrap the runner fails closed for generated code by design (safe default). - fmt.Fprintf(w, " [info] sandbox: %s\n", sandboxNote()) - trigs, _ := st.ListTriggers(ctx) - fmt.Fprintf(w, " triggers: %d, ", len(trigs)) - pend, _ := st.PendingInteractions(ctx, args[0]) - fmt.Fprintf(w, "pending interactions: %d\n", len(pend)) - if !ok { - return fmt.Errorf("doctor found problems") - } - return nil - }, -} - -func shortHash(h string) string { - if len(h) > 12 { - return h[:12] - } - return h -} -func sandboxNote() string { - if personal.SandboxAvailable() { - return "hardened (bwrap)" - } - return "no bwrap — generated code runs fail-closed unless explicitly approved" -} - -func init() { - personalCmd.AddCommand(personalHistoryCmd, personalDoctorCmd) -} diff --git a/cmd/personal_test.go b/cmd/personal_test.go deleted file mode 100644 index 6515bb8..0000000 --- a/cmd/personal_test.go +++ /dev/null @@ -1,61 +0,0 @@ -package cmd - -import ( - "bytes" - "os" - "path/filepath" - "strings" - "testing" - - gwconfig "github.com/memcode-ai/memcode/internal/gateway/config" -) - -func TestPersonalCreateListShowPauseResumeStopDelete(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg")) - exec := func(args ...string) (string, error) { - cmd := rootCmd - var out bytes.Buffer - cmd.SetOut(&out) - cmd.SetErr(&out) - cmd.SetArgs(args) - err := cmd.Execute() - return out.String(), err - } - if _, err := exec("personal", "create", "test-agent", "Maintain an arbitrary outcome"); err != nil { - t.Fatal(err) - } - cfg, err := gwconfig.Load() - if err != nil { - t.Fatal(err) - } - if cfg.Agents["test-agent"].Kind != "personal" { - t.Fatalf("agent=%+v", cfg.Agents["test-agent"]) - } - if _, err := os.Stat(filepath.Join(home, ".memcode", "agents", "test-agent", "personal.db")); err != nil { - t.Fatal(err) - } - if _, err := exec("personal", "pause", "test-agent"); err != nil { - t.Fatal(err) - } - if _, err := exec("personal", "resume", "test-agent"); err != nil { - t.Fatal(err) - } - if _, err := exec("personal", "stop", "test-agent"); err != nil { - t.Fatal(err) - } - out, err := exec("personal", "list") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(out, "test-agent") { - t.Fatalf("list output missing agent: %q", out) - } - if _, err := exec("personal", "delete", "test-agent"); err != nil { - t.Fatal(err) - } - if _, err := os.Stat(filepath.Join(home, ".memcode", "agents", "test-agent")); err != nil { - t.Fatal("non-destructive delete removed home") - } -} diff --git a/cmd/personal_triggers.go b/cmd/personal_triggers.go deleted file mode 100644 index ab84b32..0000000 --- a/cmd/personal_triggers.go +++ /dev/null @@ -1,93 +0,0 @@ -package cmd - -import ( - "fmt" - "time" - - "github.com/memcode-ai/memcode/internal/personal" - "github.com/spf13/cobra" -) - -var personalTriggersCmd = &cobra.Command{Use: "triggers", Short: "Manage persistent wake triggers"} - -var personalTriggersAddCmd = &cobra.Command{ - Use: "add ", Args: cobra.ExactArgs(3), - Short: "Add a wake trigger (interval 5m | cron \"0 * * * *\" | one-shot RFC3339)", - RunE: func(cmd *cobra.Command, args []string) error { - st, _, err := personalStoreHome(cmd, args[0]) - if err != nil { - return err - } - defer st.Close() - kind, spec := args[1], args[2] - kindMap := map[string]string{"interval": "interval", "cron": "cron", "one-shot": "one_shot"} - dbKind, ok := kindMap[kind] - if !ok { - return fmt.Errorf("kind must be interval, cron, or one-shot") - } - now := time.Now().UTC() - next, err := personal.NextDue(dbKind, spec, now) - if err != nil { - return fmt.Errorf("bad spec: %w", err) - } - id := fmt.Sprintf("trig-%s-%d", dbKind, now.Unix()) - if err := st.CreateTrigger(cmd.Context(), personal.Trigger{ID: id, ObjectiveID: "primary", Kind: dbKind, Spec: spec, NextDueAt: &next}); err != nil { - return err - } - fmt.Fprintf(cmd.OutOrStdout(), "Trigger %s added; next wake %s.\n", id, next.Format(time.RFC3339)) - return nil - }, -} - -var personalTriggersListCmd = &cobra.Command{ - Use: "list ", Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - st, _, err := personalStoreHome(cmd, args[0]) - if err != nil { - return err - } - defer st.Close() - trigs, err := st.ListTriggers(cmd.Context()) - if err != nil { - return err - } - out := cmd.OutOrStdout() - if len(trigs) == 0 { - fmt.Fprintln(out, "no triggers — the agent only wakes on `personal run` or answered interactions") - return nil - } - for _, t := range trigs { - next := "—" - if t.NextDueAt != nil { - next = t.NextDueAt.Format(time.RFC3339) - } - fmt.Fprintf(out, "- %s: %s %q next=%s [%s]\n", t.ID, t.Kind, t.Spec, next, t.Status) - } - return nil - }, -} - -func triggerSetStatus(s string) func(*cobra.Command, []string) error { - return func(cmd *cobra.Command, args []string) error { - st, _, err := personalStoreHome(cmd, args[0]) - if err != nil { - return err - } - defer st.Close() - res, err := st.DB().ExecContext(cmd.Context(), `UPDATE triggers SET status=?,updated_at=? WHERE id=?`, s, time.Now().UTC().Format(time.RFC3339Nano), args[1]) - if err != nil { - return err - } - if n, _ := res.RowsAffected(); n == 0 { - return fmt.Errorf("no trigger %q for %s", args[1], args[0]) - } - fmt.Fprintf(cmd.OutOrStdout(), "trigger %s: %s\n", args[1], s) - return nil - } -} - -func init() { - pause := &cobra.Command{Use: "pause ", Args: cobra.ExactArgs(2), RunE: triggerSetStatus("paused")} - resume := &cobra.Command{Use: "resume ", Args: cobra.ExactArgs(2), RunE: triggerSetStatus("enabled")} - personalTriggersCmd.AddCommand(personalTriggersAddCmd, personalTriggersListCmd, pause, resume) -} diff --git a/internal/agent/tools/personal.go b/internal/agent/tools/personal.go index 8cb0303..fb1a007 100644 --- a/internal/agent/tools/personal.go +++ b/internal/agent/tools/personal.go @@ -6,17 +6,19 @@ import "github.com/memcode-ai/memcode/internal/wire" // typed operations (plus ask_user). Deterministic management of Personal Agents: // objectives, policies, resources, triggers, wakes, and pending interactions. const ( - PaOverview = "pa_overview" // list all Personal Agents with status - PaCreate = "pa_create" // create a new Personal Agent (name + objective) - PaObjective = "pa_objective" // show/set an agent's objective - PaPolicy = "pa_policy" // stage/show/approve delegation policies - PaResource = "pa_resource" // grant/list/revoke resources - PaTrigger = "pa_trigger" // add/list/pause/resume wake triggers - PaWake = "pa_wake" // run one bounded wake now - PaInbox = "pa_inbox" // list pending human interactions - PaAnswer = "pa_answer" // answer a pending interaction - PaHistory = "pa_history" // recent runs + journaled actions - PaLifecycle = "pa_lifecycle" // pause/resume/stop/delete an agent + PaOverview = "pa_overview" // list all Personal Agents with status + PaCreate = "pa_create" // create a new Personal Agent (name + objective) + PaObjective = "pa_objective" // show/set an agent's objective + PaPolicy = "pa_policy" // stage/show/approve delegation policies + PaResource = "pa_resource" // grant/list/revoke resources + PaTrigger = "pa_trigger" // add/list/pause/resume wake triggers + PaWake = "pa_wake" // run one bounded wake now + PaInbox = "pa_inbox" // list pending human interactions + PaAnswer = "pa_answer" // answer a pending interaction + PaHistory = "pa_history" // recent runs + journaled actions + PaLifecycle = "pa_lifecycle" // pause/resume/stop/delete an agent + PaDoctor = "pa_doctor" // health check: home layout, objective, policy, sandbox, triggers, pending interactions + PaBrowserSetup = "pa_browser_setup" // check/connect existing-Chrome delegation prerequisites ) // PersonalDefs returns the personal-cockpit tool registry. @@ -116,5 +118,17 @@ func PersonalDefs() []wire.ToolDef { "delete_home": str("delete only: 'true' to also permanently delete the agent home"), }, "agent", "action"), }, + { + Name: PaDoctor, + Description: "Health check an agent: home directory layout, objective, approved policy, generated workspace, sandbox availability, trigger count, pending interaction count. Use when something seems wrong or before a first wake.", + InputSchema: obj(map[string]any{ + "agent": str("agent name"), + }, "agent"), + }, + { + Name: PaBrowserSetup, + Description: "Check existing-Chrome delegation prerequisites (npx, the gateway's browser broker) and attempt a real, bounded connection to the user's running Chrome. Call this when a delegate call with a browser toolset fails closed, or when the user asks to set up browser access. Does not click Chrome's own consent dialog — only the user can do that.", + InputSchema: obj(map[string]any{}), + }, } } From c3a84430ed38ca3bcc6189c68967085a26202cd9 Mon Sep 17 00:00:00 2001 From: Tim Erwin Date: Sun, 30 Aug 2026 14:13:17 +0700 Subject: [PATCH 07/13] personal: one config.yaml instead of three scattered mirror files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit objective.md/policy.yaml/resources.yaml split one agent's config across three files by table for no real reason — a person has to know to look in three places for one agent's setup. Consolidated into a single config.yaml (objective, policies, resources as sections) with proper snake_case field names. Same authority split as before (this is a mirror; policy hash- approval and the run journal stay in personal.db, and why, is unchanged) — just one file to read instead of three. --- cmd/personal_cmd_test.go | 10 ++-- internal/personal/mirror.go | 106 +++++++++++++++++++----------------- 2 files changed, 60 insertions(+), 56 deletions(-) diff --git a/cmd/personal_cmd_test.go b/cmd/personal_cmd_test.go index e8c3f0c..61cb6b4 100644 --- a/cmd/personal_cmd_test.go +++ b/cmd/personal_cmd_test.go @@ -104,9 +104,9 @@ func TestPaPolicyLifecycleBlocksThenApproves(t *testing.T) { } // Config mirror actually reflects the approved policy. - mirrored, err := os.ReadFile(filepath.Join(home, "policy.yaml")) + mirrored, err := os.ReadFile(filepath.Join(home, "config.yaml")) if err != nil || !strings.Contains(string(mirrored), "approved: true") { - t.Fatalf("policy.yaml mirror missing approval: %v %q", err, mirrored) + t.Fatalf("config.yaml mirror missing approval: %v %q", err, mirrored) } } @@ -155,11 +155,11 @@ func TestPaResourceAndTrigger(t *testing.T) { t.Fatalf("expected revoked: %q", out) } - // resources.yaml mirror reflects the revoke. + // Config mirror reflects the revoke. agentHome, _ := gwconfig.AgentHome("pa2") - mirrored, err := os.ReadFile(filepath.Join(agentHome, "resources.yaml")) + mirrored, err := os.ReadFile(filepath.Join(agentHome, "config.yaml")) if err != nil || !strings.Contains(string(mirrored), "revoked") { - t.Fatalf("resources.yaml mirror missing revoke: %v %q", err, mirrored) + t.Fatalf("config.yaml mirror missing revoke: %v %q", err, mirrored) } } diff --git a/internal/personal/mirror.go b/internal/personal/mirror.go index feef094..f671b5b 100644 --- a/internal/personal/mirror.go +++ b/internal/personal/mirror.go @@ -3,7 +3,6 @@ package personal import ( "context" "encoding/json" - "fmt" "path/filepath" yaml "go.yaml.in/yaml/v4" @@ -11,81 +10,86 @@ import ( "github.com/memcode-ai/memcode/internal/atomicfile" ) -// WriteConfigMirror regenerates the agent home's human-readable config files -// from the current DB state: objective.md, policy.yaml, resources.yaml. This -// is what makes `ls ~/.memcode/agents//` show something a person can -// actually read, diff, and grep, instead of only a personal.db blob reachable -// through bespoke CLI commands — every OTHER piece of memcode config -// (gateway.yaml, .mcp.json, CLAUDE.md, skills) is a plain file; Personal -// Agents' setup/config surface should be too. +// WriteConfigMirror regenerates config.yaml — ONE file in the agent's home +// with everything a human decided (objective, policies, resource grants) — +// from the current DB state. This is what makes `ls ~/.memcode/agents//` +// show something a person can actually read, diff, and grep, instead of only +// a personal.db blob reachable through bespoke operations — every OTHER +// piece of memcode config (gateway.yaml, .mcp.json, CLAUDE.md, skills) is a +// plain file; Personal Agents' setup/config surface should be too, and it +// should be ONE file, not several scattered by table. // -// These files are a MIRROR, not the source of truth — the DB stays -// authoritative for two reasons that are correctness, not habit: +// 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 Personal Agent runs unsupervised, so "the document a // human actually reviewed" must be pinned by hash, not re-derived from -// whatever a file happens to say at wake time. Editing policy.yaml and -// having it silently take effect would defeat that. +// 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, the CLI, and -// the cockpit can all touch the same agent) — a SQL transaction gives -// that almost for free; flat files would need to reinvent it (see the +// semantics under concurrent access (the gateway wake loop and the +// cockpit 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). +// file write loses that property). So the run journal stays out of this +// file entirely — use pa_history for that. // -// So: objective/policy/resources — the SETUP a human decides — mirror out as -// files for inspection. The RUN journal stays in personal.db. Called after -// every mutation to those three (CreateObjective, InsertResource, -// ApprovePolicy, etc.) — best-effort: a mirror failure never blocks the -// underlying DB write, which already succeeded. +// Called after every mutation to objective/policy/resources (paCreate, +// paResource grant/revoke, paPolicy stage/approve) — best-effort: a mirror +// failure never blocks the underlying DB write, which already succeeded. func WriteConfigMirror(ctx context.Context, home string, s *Store) error { - obj, hasObj, err := s.GetObjective(ctx, "primary") - if err != nil { - return err + 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"` + } + type objectiveView struct { + Description string `yaml:"description"` + SuccessCriteria string `yaml:"success_criteria,omitempty"` + Status string `yaml:"status"` } - if hasObj { - md := fmt.Sprintf("# Objective\n\n%s\n\n**Status:** %s\n", obj.Description, obj.Status) - if obj.SuccessCriteria != "" { - md += fmt.Sprintf("\n**Success criteria:** %s\n", obj.SuccessCriteria) - } - if err := atomicfile.WriteFile(filepath.Join(home, "objective.md"), []byte(md), 0o600); err != nil { - return err - } + cfg := struct { + Objective *objectiveView `yaml:"objective,omitempty"` + Policies []policyView `yaml:"policies,omitempty"` + Resources []resourceView `yaml:"resources,omitempty"` + }{} + + if obj, hasObj, err := s.GetObjective(ctx, "primary"); err != nil { + return err + } else if hasObj { + cfg.Objective = &objectiveView{Description: obj.Description, SuccessCriteria: obj.SuccessCriteria, Status: obj.Status} } policies, err := s.ListPolicies(ctx, "primary") if err != nil { return err } - type policyView struct { - Hash, Status string - Version int - Approved bool - Document map[string]any `yaml:"document"` - } - var pv []policyView for _, p := range policies { var doc map[string]any _ = json.Unmarshal(p.Document, &doc) - pv = append(pv, policyView{Hash: p.Hash, Status: p.Status, Version: p.Version, Approved: p.Status == "approved", Document: doc}) - } - if pb, err := yaml.Marshal(map[string]any{"policies": pv}); err == nil { - _ = atomicfile.WriteFile(filepath.Join(home, "policy.yaml"), pb, 0o600) + 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 } - type resourceView struct { - ID, Type, Locator, AccessMode, Status string - } - var rv []resourceView for _, r := range res { - rv = append(rv, resourceView{ID: r.ID, Type: r.Type, Locator: r.Locator, AccessMode: r.AccessMode, Status: r.Status}) + cfg.Resources = append(cfg.Resources, resourceView{ID: r.ID, Type: r.Type, Locator: r.Locator, AccessMode: r.AccessMode, Status: r.Status}) } - if rb, err := yaml.Marshal(map[string]any{"resources": rv}); err == nil { - _ = atomicfile.WriteFile(filepath.Join(home, "resources.yaml"), rb, 0o600) + + b, err := yaml.Marshal(cfg) + if err != nil { + return err } - return nil + return atomicfile.WriteFile(filepath.Join(home, "config.yaml"), b, 0o600) } From 47c500606c618dbe363474267915ebcd97988e1a Mon Sep 17 00:00:00 2001 From: Tim Erwin Date: Sun, 30 Aug 2026 15:51:45 +0700 Subject: [PATCH 08/13] continuation: one durable suspend/resume implementation, not three MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidation step 1 of folding Personal Agents into the ordinary agent system. There were three partial suspend/resume designs in the tree: 1. internal/agent/runtime/continuation.go — typed, atomic, tested, and with ZERO production callers. 2. jobs.Job's InteractionID/WaitingReason/ContinuationVersion/WaitingAt/ ResumedAt — declared, never written by anything. 3. A hand-rolled map[string]any in the personal executive — the only one actually running, and (until a fix earlier in this branch) the only one that wasn't crash-safe. New internal/agent/continuation is the single implementation. It lives in its own small package rather than in internal/agent/runtime so the executive can suspend without importing the whole session runtime (and so neither side ends up depending on the other). It has to serve two genuinely different callers, which is why the previous attempt at a shared API didn't fit: - An interactive session already holds the conversation, so it only needs the missing assistant/answer pair back. - An unattended executive keeps NO transcript — it rebuilds context from durable state each wake — so its continuation must carry the whole conversation. Hence the optional Messages field. They also differ on when marking resolved is safe, so building the resume messages is split from marking: - ResumeMessages() builds without marking. The executive uses this and marks only once the resumed run reaches a terminal state, so a transient model error leaves the answer re-giveable instead of stranding it. - Resolve() does both, for a caller with a human right there who can simply ask again. Also deleted the five never-written Job fields (2 above). StatusWaiting stays: it is a meaningful status in the job state machine and check_delegate correctly treats it as non-terminal. Tests: ported both original round-trip/validation tests, plus new coverage for the transcript-carrying path and MarkResolved. The executive's suspend/resume test now asserts through the continuation API instead of a hardcoded filename. --- internal/agent/continuation/continuation.go | 176 ++++++++++++++++++ .../agent/continuation/continuation_test.go | 111 +++++++++++ internal/agent/runtime/continuation.go | 104 ----------- internal/agent/runtime/continuation_test.go | 54 ------ internal/jobs/jobs.go | 36 ++-- internal/personal/runner_exec.go | 77 +++----- internal/personal/runner_exec_test.go | 8 +- 7 files changed, 341 insertions(+), 225 deletions(-) create mode 100644 internal/agent/continuation/continuation.go create mode 100644 internal/agent/continuation/continuation_test.go delete mode 100644 internal/agent/runtime/continuation.go delete mode 100644 internal/agent/runtime/continuation_test.go 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/continuation.go b/internal/agent/runtime/continuation.go deleted file mode 100644 index 25cec27..0000000 --- a/internal/agent/runtime/continuation.go +++ /dev/null @@ -1,104 +0,0 @@ -package runtime - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "time" - - "github.com/memcode-ai/memcode/internal/atomicfile" - "github.com/memcode-ai/memcode/internal/wire" -) - -type Outcome string - -const ( - OutcomeCompleted Outcome = "completed" - OutcomeFailed Outcome = "failed" - OutcomeSuspended Outcome = "suspended" -) - -type Suspension struct { - Version int `json:"version"` - SessionID, InteractionID, ToolUseID, ToolName string - ToolInput json.RawMessage - Assistant wire.Message - CreatedAt time.Time - Resolved bool -} - -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 -} - -func suspensionPath(root, sessionID, interactionID string) string { - return filepath.Join(root, ".memcode", "sessions", sessionID, "continuations", interactionID+".json") -} - -func SaveSuspension(root 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 - } - p := suspensionPath(root, s.SessionID, s.InteractionID) - if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil { - return err - } - b, err := json.Marshal(s) - if err != nil { - return err - } - return atomicfile.WriteFile(p, b, 0o600) -} - -func LoadSuspension(root, sessionID, interactionID string) (Suspension, error) { - b, err := os.ReadFile(suspensionPath(root, sessionID, 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 -} - -func ResolveSuspension(root string, s Suspension, 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) - } - s.Resolved = true - p := suspensionPath(root, s.SessionID, s.InteractionID) - b, err := json.Marshal(s) - if err != nil { - return nil, err - } - if err := atomicfile.WriteFile(p, b, 0o600); err != nil { - return nil, err - } - return []wire.Message{s.Assistant, {Role: "user", Blocks: []wire.Block{result}}}, nil -} diff --git a/internal/agent/runtime/continuation_test.go b/internal/agent/runtime/continuation_test.go deleted file mode 100644 index dfc7efa..0000000 --- a/internal/agent/runtime/continuation_test.go +++ /dev/null @@ -1,54 +0,0 @@ -package runtime - -import ( - "encoding/json" - "testing" - - "github.com/memcode-ai/memcode/internal/wire" -) - -func TestSuspensionRoundTripPreservesReasoningAndTool(t *testing.T) { - root := 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 := SaveSuspension(root, s); err != nil { - t.Fatal(err) - } - got, err := LoadSuspension(root, "session-1", "interaction-1") - if err != nil { - t.Fatal(err) - } - if got.Assistant.Blocks[0].Signature != "sig" || got.ToolUseID != "tool-1" { - t.Fatalf("suspension=%+v", got) - } - msgs, err := ResolveSuspension(root, 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 := LoadSuspension(root, "session-1", "interaction-1"); err == nil { - t.Fatal("resolved suspension loaded again") - } -} - -func TestSuspensionRejectsMixedBatchAndMismatchedResult(t *testing.T) { - root := t.TempDir() - mixed := wire.Message{Role: "assistant", Blocks: []wire.Block{{Type: "tool_use", ID: "a"}, {Type: "tool_use", ID: "b"}}} - if err := SaveSuspension(root, Suspension{SessionID: "s", InteractionID: "i", ToolUseID: "a", Assistant: mixed}); err == nil { - t.Fatal("mixed tool batch accepted") - } - single := wire.Message{Role: "assistant", Blocks: []wire.Block{{Type: "tool_use", ID: "a", Name: "approval"}}} - s := Suspension{SessionID: "s", InteractionID: "i2", ToolUseID: "a", Assistant: single} - if err := SaveSuspension(root, s); err != nil { - t.Fatal(err) - } - loaded, err := LoadSuspension(root, "s", "i2") - if err != nil { - t.Fatal(err) - } - if _, err := ResolveSuspension(root, loaded, wire.Block{Type: "tool_result", ToolUseID: "wrong"}); err == nil { - t.Fatal("mismatched result accepted") - } -} diff --git a/internal/jobs/jobs.go b/internal/jobs/jobs.go index 91d90b7..7b6a354 100644 --- a/internal/jobs/jobs.go +++ b/internal/jobs/jobs.go @@ -76,23 +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"` - 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"` - InteractionID string `json:"interaction_id,omitempty"` - WaitingReason string `json:"waiting_reason,omitempty"` - ContinuationVersion int `json:"continuation_version,omitempty"` - WaitingAt time.Time `json:"waiting_at,omitempty"` - ResumedAt time.Time `json:"resumed_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 diff --git a/internal/personal/runner_exec.go b/internal/personal/runner_exec.go index c17fe61..0e9a80c 100644 --- a/internal/personal/runner_exec.go +++ b/internal/personal/runner_exec.go @@ -9,7 +9,7 @@ import ( "strings" "time" - "github.com/memcode-ai/memcode/internal/atomicfile" + "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" @@ -736,24 +736,23 @@ func (e *Executive) writeGranted(path, content string) error { 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 so resume replays nothing already done. +// 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 { - dir := filepath.Join(home, "runs", runID) - if err := os.MkdirAll(dir, 0o700); err != nil { - return err - } - s := map[string]any{ - "version": 1, "interaction_id": interactionID, "run_id": runID, - "tool_use_id": call.ID, "tool_name": call.Name, "tool_input": json.RawMessage(call.Input), - "assistant": assistant, "messages": msgs, - "created_at": time.Now().UTC().Format(time.RFC3339Nano), "resolved": false, - } - b, err := json.Marshal(s) - if err != nil { - return err - } - return atomicfile.WriteFile(filepath.Join(dir, "suspension-"+interactionID+".json"), b, 0o600) + 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. @@ -763,21 +762,10 @@ func writeSuspension(home, runID, interactionID string, call wire.Block, assista // 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) { - path := filepath.Join(e.Home, "runs", in.RunID, "suspension-"+in.ID+".json") - b, err := os.ReadFile(path) + dir := suspensionDir(e.Home, in.RunID) + s, err := continuation.Load(dir, in.ID) if err != nil { - return RunOutcome{}, fmt.Errorf("no continuation for interaction %q: %w", in.ID, err) - } - var s struct { - Resolved bool `json:"resolved"` - ToolUseID string `json:"tool_use_id"` - Messages []wire.Message `json:"messages"` - } - if err := json.Unmarshal(b, &s); err != nil { - return RunOutcome{}, err - } - if s.Resolved { - return RunOutcome{}, fmt.Errorf("interaction %q continuation already resolved", in.ID) + 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") @@ -793,26 +781,21 @@ func (e *Executive) ResumeSuspended(ctx context.Context, in Interaction, answer } tools := e.allowedTools(policyDoc) - // Append the exact tool result matching the suspended tool_use_id. - msgs := append([]wire.Message{}, s.Messages...) - msgs = append(msgs, wire.Message{Role: "user", Blocks: []wire.Block{{ - Type: "tool_result", ToolUseID: s.ToolUseID, Content: answer, - }}}) + // 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 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 file already carries the appended answer forward). Only a hard - // resume error (returned above) leaves this continuation retryable. + // 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" { - var raw map[string]any - if json.Unmarshal(b, &raw) == nil { - raw["resolved"] = true - if rb, err := json.Marshal(raw); err == nil { - _ = atomicfile.WriteFile(path, rb, 0o600) - } - } + _ = 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/personal/runner_exec_test.go b/internal/personal/runner_exec_test.go index 87b9473..392fddf 100644 --- a/internal/personal/runner_exec_test.go +++ b/internal/personal/runner_exec_test.go @@ -9,6 +9,7 @@ import ( "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" @@ -147,9 +148,10 @@ func TestExecutiveSuspendsAndResumes(t *testing.T) { if len(pend) != 1 || pend[0].Question != "proceed with upgrade?" { t.Fatalf("inbox=%v", pend) } - // Continuation file exists. - if _, err := os.Stat(filepath.Join(home, "runs", out.RunID, "suspension-"+out.InteractionID+".json")); err != nil { - t.Fatalf("continuation missing: %v", err) + // 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). From cafdfe8debea7bf20e5eba8ce1c60b4a45e7c7c7 Mon Sep 17 00:00:00 2001 From: Tim Erwin Date: Sun, 30 Aug 2026 16:05:18 +0700 Subject: [PATCH 09/13] =?UTF-8?q?agents:=20autonomy=20is=20a=20mode,=20not?= =?UTF-8?q?=20a=20species=20=E2=80=94=20fold=20Personal=20into=20gw=5F*=20?= =?UTF-8?q?/=20admin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidation step 2. "Personal Agent" is no longer a kind of agent. There is one Agent abstraction; autonomy is orthogonal settings on it, managed through the admin cockpit that already manages agents. CONFIG. gwconfig.Agent drops Kind and gains four settings: objective — the durable outcome it works toward autonomous — whether it may act on that unprompted browser — ephemeral (default) | existing_chrome paused — stop unattended wakes without deleting anything objective and autonomous are deliberately SEPARATE grants, which is the correction that motivated this design. They answer different questions ("what is it for" vs "may it act unasked"), and all four combinations are meaningful: autonomous + objective → unattended objective pursuit (the old Personal Agent) autonomous, no objective→ scheduled work UNDER GOVERNANCE — this is new, and closes the long-standing gap where a cron-fired agent ran unattended with no policy gate, no action journal, and no way to pause and ask objective, not autonomous → a goal you work on together; wakes only on demand neither → an ordinary conversational agent, exactly as before Autonomy therefore gates GOVERNANCE, not capability, and applies to any run of the agent whether or not an objective exists. SCHEDULING. The second cron implementation is gone as a user-facing concept. Recurring cadence is an ordinary `schedules:` entry: set agent=, leave deliver_to empty, and gw_schedule routes the wake to the agent itself via the renamed internal sink (personal → agent). The DB-backed loop now fires only the agent's OWN self-scheduled next-wakes (schedule_wake, "come back in 45 minutes") — a genuinely different thing from human-authored cadence, and the only part that needs to be writable from inside a run. TOOLS. internal/agent/tools/personal.go (pa_*) is deleted. The admin registry gains gw_policy, gw_grant, gw_wake, gw_inbox, gw_answer, gw_journal, gw_doctor, gw_browser; gw_agent gains objective/autonomous/browser/pause/resume actions. Everything dispatches through the existing adminExecute + approval gate, and gw_schedule's shared BuildSchedule validation now covers agent cadence too, so the surfaces cannot drift the way the parallel path did. COCKPIT. `memcode personal` is deleted outright — cmd/personal.go, cmd/personal_cockpit.go, Session.SetPersonal, personalMode, the personal and personal_admin doctrines. The Personal setup walkthrough is merged into adminDoctrine, generalized, and now teaches the two gates explicitly: granting an objective is not granting autonomy, and the second must be confirmed on its own. EXECUTIVE. Executive.Objective is read from configuration rather than the store, so the objective has ONE source that a human edits and the gateway hot-reloads. The per-agent config.yaml mirror drops the objective for the same reason — it lives in gateway.yaml, which is already a readable file. Tests are rewritten against adminExecute (there is no other surface). New coverage asserts the orthogonality directly, since a single overloaded switch is exactly what this change exists to prevent: an objective alone must not confer autonomy, a non-affirmative value must not grant it by typo, an autonomous agent with no objective is still governed but refuses to invent work, and an autonomous agent's schedule defaults to the agent route. --- cmd/admin_autonomy.go | 373 +++++++++++++ cmd/admin_autonomy_test.go | 246 +++++++++ cmd/admin_tools.go | 189 ++++++- cmd/personal.go | 86 --- cmd/personal_cmd_test.go | 186 ------- cmd/personal_cockpit.go | 605 ---------------------- cmd/run.go | 2 +- internal/agent/runtime/admin.go | 57 +- internal/agent/runtime/exec.go | 16 +- internal/agent/runtime/prompts.go | 3 - internal/agent/runtime/runtime.go | 15 +- internal/agent/tools/admin.go | 93 +++- internal/agent/tools/personal.go | 134 ----- internal/browser/broker/server.go | 2 +- internal/doctrine/prompts.go | 140 +++-- internal/gateway/config/config.go | 55 +- internal/gateway/config/config_test.go | 42 +- internal/gateway/server/autonomy.go | 181 +++++++ internal/gateway/server/personal.go | 157 ------ internal/gateway/server/scheduler_test.go | 18 +- internal/gateway/server/server.go | 36 +- internal/personal/mirror.go | 43 +- internal/personal/runner_exec.go | 47 +- internal/personal/runner_exec_test.go | 10 +- 24 files changed, 1298 insertions(+), 1438 deletions(-) create mode 100644 cmd/admin_autonomy.go create mode 100644 cmd/admin_autonomy_test.go delete mode 100644 cmd/personal.go delete mode 100644 cmd/personal_cmd_test.go delete mode 100644 cmd/personal_cockpit.go delete mode 100644 internal/agent/tools/personal.go create mode 100644 internal/gateway/server/autonomy.go delete mode 100644 internal/gateway/server/personal.go diff --git a/cmd/admin_autonomy.go b/cmd/admin_autonomy.go new file mode 100644 index 0000000..5837a0c --- /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/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/personal" + "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) (*personal.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 := personal.Open(ctx, home) + if err != nil { + return nil, "", gwconfig.Agent{}, err + } + return st, home, a, nil +} + +func gwPolicy(ctx context.Context, st *personal.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 personal.DelegationPolicy + if err := json.Unmarshal([]byte(document), &doc); err != nil { + return "", fmt.Errorf("document is not valid DelegationPolicy JSON: %w", err) + } + canon, h, err := personal.CanonicalPolicy(doc) + if err != nil { + return "", err + } + ver, err := st.NextPolicyVersion(ctx, "primary") + if err != nil { + return "", err + } + if err := st.InsertPolicy(ctx, personal.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) + _ = personal.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 + } + _ = personal.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 *personal.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 := personal.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, personal.Resource{ID: rid, ObjectiveID: "primary", Type: rtype, Locator: locator, AccessMode: mode, AuthorizationSource: "admin", Status: "active"}); err != nil { + return "", err + } + _ = personal.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 + } + _ = personal.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 *personal.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 := &personal.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 *personal.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 *personal.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 := &personal.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 *personal.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 *personal.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 := personal.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 personal.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..4e0f9fb --- /dev/null +++ b/cmd/admin_autonomy_test.go @@ -0,0 +1,246 @@ +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) + } +} diff --git a/cmd/admin_tools.go b/cmd/admin_tools.go index 6b9ab6d..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,8 +179,17 @@ func adminOverview(ctx context.Context) (string, error) { for _, name := range agentNames { a := settings.Agents[name] extra := "" - if a.Kind != "" { - extra += " kind=" + a.Kind + 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 @@ -389,11 +447,13 @@ func adminAgent(input json.RawMessage) (string, error) { var in struct { Action string `json:"action"` Name string `json:"name"` - Kind string `json:"kind"` Model string `json:"model"` 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 @@ -415,18 +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") } - kind := strings.TrimSpace(in.Kind) - if kind != "" && kind != "personal" { - return "", fmt.Errorf("kind must be empty or personal") + 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 + } + 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) } - settings.Agents[name] = gwconfig.Agent{Kind: kind, Model: strings.TrimSpace(in.Model), Reasoning: strings.TrimSpace(in.Reasoning)} + on := isTrue(in.Autonomous) + p.Autonomous = on + settings.Agents[name] = p if err := gwconfig.Save(settings); err != nil { return "", err } - if kind == "personal" { - return fmt.Sprintf("Created Personal Agent %s. Manage its objective and lifecycle with `memcode personal`; its home is retained independently at ~/.memcode/agents/%s/.", name, name), nil + if !on { + return fmt.Sprintf("%s will no longer run unattended. Scheduled wakes stop; it still answers on demand.", name), nil } - 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 + 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 { @@ -498,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) { @@ -526,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.go b/cmd/personal.go deleted file mode 100644 index 6903780..0000000 --- a/cmd/personal.go +++ /dev/null @@ -1,86 +0,0 @@ -package cmd - -import ( - "context" - "os" - "path/filepath" - - "github.com/memcode-ai/memcode/internal/agent/permissions" - agentrt "github.com/memcode-ai/memcode/internal/agent/runtime" - appconfig "github.com/memcode-ai/memcode/internal/config" - "github.com/memcode-ai/memcode/internal/llm" - "github.com/memcode-ai/memcode/internal/provider" - "github.com/memcode-ai/memcode/internal/store" - "github.com/memcode-ai/memcode/internal/vxui" - "github.com/spf13/cobra" -) - -// personalCmd opens the Personal Agents cockpit. There is no CLI subcommand -// surface here on purpose: every operation (create, list, show, grant a -// resource, stage/approve a policy, add a trigger, run a wake, answer a -// question, check health, pause/stop/delete) is a typed pa_* tool the -// cockpit's own model calls directly (see cmd/personal_cockpit.go and -// internal/agent/tools/personal.go) — you say what you want, in plain -// language, and it does the work. `memcode personal` is the entire interface. -var personalCmd = &cobra.Command{ - Use: "personal", - Short: "Open the Personal Agents cockpit — an interactive agent that manages your long-lived Personal Agents", - Long: `Open the Personal Agents cockpit — an interactive session (like ` + "`memcode admin`" + `) -that manages your long-lived Personal Agents by conversation. Just say what -you want: create one, tell it what files or capabilities it needs, review -and approve its policy, set its wake schedule, check on it, answer a -question it's stuck on, or shut it down — all through talking to it, not -through CLI commands.`, - RunE: func(cmd *cobra.Command, args []string) error { - return runPersonalCockpit(cmd.Context()) - }, -} - -// runPersonalCockpit opens the interactive Personal Agents session. It mirrors -// `memcode admin`: a TUI over the memcode home, with the pa_* typed tools and no -// repo/coding tools. Personal Agent state lives under ~/.memcode/agents//. -func runPersonalCockpit(ctx context.Context) error { - home, err := os.UserHomeDir() - if err != nil { - return err - } - root := filepath.Join(home, ".memcode") - if err := os.MkdirAll(root, 0o700); err != nil { - return err - } - if _, err := appconfig.Init(root, false); err != nil { - return err - } - cfg, err := appconfig.Load(root) - if err != nil { - return err - } - st, err := store.Open(ctx, storePath(cfg.Root)) - if err != nil { - return err - } - defer st.Close() - - provider.LoadDotEnv() - maybeRunFirstRunWizard(ctx, cfg) - var endpoints []provider.Endpoint - if ep, ok := cfg.ResolveEndpoint(); ok { - endpoints = append(endpoints, ep) - } - prov := provider.NewFromEnvLazy(endpoints...) - model := provider.EffectiveModel(cfg.Models.Coder) - sess := agentrt.New(st, llm.NewRunner(prov), cfg.Root, model, permissions.ModeAsk, os.Stdout) - sess.SetPersonal(personalExecute) - if ep, onEndpoint := prov.Endpoint(); onEndpoint { - sess.SetPin(ep.Model, provider.CatalogWindow(ep.Model)) - } else { - sess.SetVendor(cfg.Vendor) - sess.SetPin(cfg.PinnedModel, cfg.PinnedWindow) - } - sess.SetServingDefault(cfg.ServingDefault) - return vxui.Run(ctx, sess, cfg.Theme) -} - -func init() { - rootCmd.AddCommand(personalCmd) -} diff --git a/cmd/personal_cmd_test.go b/cmd/personal_cmd_test.go deleted file mode 100644 index 61cb6b4..0000000 --- a/cmd/personal_cmd_test.go +++ /dev/null @@ -1,186 +0,0 @@ -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" -) - -// setupPersonalHome isolates HOME/XDG_CONFIG_HOME so a test's Personal Agents -// never touch the real ~/.memcode or ~/.config/memcode. -func setupPersonalHome(t *testing.T) string { - t.Helper() - home := t.TempDir() - t.Setenv("HOME", home) - t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg")) - return home -} - -// call runs one pa_* tool exactly as the cockpit would — this IS the -// interface now (see cmd/personal.go): there is no CLI subcommand path to -// fall back to, so every test here goes through personalExecute. -func call(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 := personalExecute(context.Background(), name, b) - if err != nil { - t.Fatalf("%s(%v): %v", name, in, err) - } - return out -} - -func TestPaCreateListPauseResumeStopDelete(t *testing.T) { - home := setupPersonalHome(t) - call(t, tools.PaCreate, map[string]any{"agent": "test-agent", "objective": "Maintain an arbitrary outcome"}) - - cfg, err := gwconfig.Load() - if err != nil { - t.Fatal(err) - } - if cfg.Agents["test-agent"].Kind != "personal" { - t.Fatalf("agent=%+v", cfg.Agents["test-agent"]) - } - if _, err := os.Stat(filepath.Join(home, ".memcode", "agents", "test-agent", "personal.db")); err != nil { - t.Fatal(err) - } - - call(t, tools.PaLifecycle, map[string]any{"agent": "test-agent", "action": "pause"}) - call(t, tools.PaLifecycle, map[string]any{"agent": "test-agent", "action": "resume"}) - call(t, tools.PaLifecycle, map[string]any{"agent": "test-agent", "action": "stop"}) - - out := call(t, tools.PaOverview, map[string]any{}) - if !strings.Contains(out, "test-agent") { - t.Fatalf("overview missing agent: %q", out) - } - - call(t, tools.PaLifecycle, map[string]any{"agent": "test-agent", "action": "delete"}) - if _, err := os.Stat(filepath.Join(home, ".memcode", "agents", "test-agent")); err != nil { - t.Fatal("non-destructive delete removed home") - } -} - -func TestPaPolicyLifecycleBlocksThenApproves(t *testing.T) { - setupPersonalHome(t) - call(t, tools.PaCreate, map[string]any{"agent": "pa", "objective": "Keep things tidy"}) - - // No policy yet → wake is blocked. - out := call(t, tools.PaWake, map[string]any{"agent": "pa"}) - if !strings.Contains(out, "blocked") { - t.Fatalf("expected blocked wake, 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 = call(t, tools.PaPolicy, map[string]any{"agent": "pa", "action": "stage", "document": string(pb)}) - if !strings.Contains(out, "Draft policy v1 staged") { - t.Fatalf("stage=%q", out) - } - home, _ := gwconfig.AgentHome("pa") - entries, err := os.ReadDir(filepath.Join(home, "policies")) - if err != nil || len(entries) != 1 { - t.Fatalf("policies dir: %v %v", entries, err) - } - hash := strings.TrimSuffix(entries[0].Name(), ".json") - - out = call(t, tools.PaPolicy, map[string]any{"agent": "pa", "action": "approve", "hash": hash[:12]}) - if !strings.Contains(out, "Approved policy") { - t.Fatalf("approve=%q", out) - } - out = call(t, tools.PaPolicy, map[string]any{"agent": "pa", "action": "show"}) - if !strings.Contains(out, "approved policy v1") { - t.Fatalf("show=%q", out) - } - - // Config mirror actually reflects the approved policy. - mirrored, err := os.ReadFile(filepath.Join(home, "config.yaml")) - if err != nil || !strings.Contains(string(mirrored), "approved: true") { - t.Fatalf("config.yaml mirror missing approval: %v %q", err, mirrored) - } -} - -func TestPaResourceAndTrigger(t *testing.T) { - home := setupPersonalHome(t) - call(t, tools.PaCreate, map[string]any{"agent": "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 a person would actually ask for. - out := call(t, tools.PaResource, 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 = call(t, tools.PaResource, map[string]any{"agent": "pa2", "action": "list"}) - if !strings.Contains(out, "filesystem") { - t.Fatalf("list=%q", out) - } - - out = call(t, tools.PaTrigger, map[string]any{"agent": "pa2", "action": "add", "kind": "interval", "spec": "30m"}) - if !strings.Contains(out, "next wake") { - t.Fatalf("trigger add=%q", out) - } - out = call(t, tools.PaTrigger, map[string]any{"agent": "pa2", "action": "list"}) - if !strings.Contains(out, "interval") { - t.Fatalf("trigger list=%q", out) - } - - // Revoke by parsing the id out of the list output. - out = call(t, tools.PaResource, 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) - } - call(t, tools.PaResource, map[string]any{"agent": "pa2", "action": "revoke", "id": resID}) - out = call(t, tools.PaResource, map[string]any{"agent": "pa2", "action": "list"}) - if !strings.Contains(out, "[revoked]") { - t.Fatalf("expected revoked: %q", out) - } - - // Config mirror reflects the revoke. - 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) - } -} - -func TestPaDoctorAndOverview(t *testing.T) { - setupPersonalHome(t) - call(t, tools.PaCreate, map[string]any{"agent": "cock", "objective": "Tidy my notes"}) - - out := call(t, tools.PaOverview, map[string]any{}) - if !strings.Contains(out, "cock") { - t.Fatalf("overview=%q", out) - } - out = call(t, tools.PaObjective, map[string]any{"agent": "cock", "action": "show"}) - if !strings.Contains(out, "Tidy my notes") { - t.Fatalf("objective=%q", out) - } - out = call(t, tools.PaDoctor, map[string]any{"agent": "cock"}) - if !strings.Contains(out, "objective") || !strings.Contains(out, "sandbox") { - t.Fatalf("doctor=%q", out) - } - out = call(t, tools.PaInbox, map[string]any{"agent": "cock"}) - if !strings.Contains(out, "inbox empty") { - t.Fatalf("inbox=%q", out) - } -} diff --git a/cmd/personal_cockpit.go b/cmd/personal_cockpit.go deleted file mode 100644 index 2bc95f7..0000000 --- a/cmd/personal_cockpit.go +++ /dev/null @@ -1,605 +0,0 @@ -package cmd - -// The Personal Agents cockpit executor: typed pa_* operations over Personal -// Agent state, injected into the runtime (same seam as admin). Secrets and the -// model backend are out of scope here except for pa_wake/pa_answer, which spin -// up a provider for that single wake/resume. - -import ( - "context" - "encoding/json" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - "time" - - "github.com/memcode-ai/memcode/internal/agent/tools" - "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/personal" - "github.com/memcode-ai/memcode/internal/provider" -) - -func paStore(ctx context.Context, agent string) (*personal.Store, string, error) { - s, err := gwconfig.Load() - if err != nil { - return nil, "", err - } - a, ok := s.Agents[agent] - if !ok || a.Kind != "personal" { - return nil, "", fmt.Errorf("no Personal Agent %q", agent) - } - home, err := gwconfig.AgentHome(agent) - if err != nil { - return nil, "", err - } - st, err := personal.Open(ctx, home) - if err != nil { - return nil, "", err - } - return st, home, nil -} - -func personalExecute(ctx context.Context, name string, input json.RawMessage) (string, error) { - var in struct { - Agent string `json:"agent"` - Action string `json:"action"` - Text string `json:"text"` - Objective string `json:"objective"` - Document string `json:"document"` - Hash string `json:"hash"` - Type string `json:"type"` - Locator string `json:"locator"` - Mode string `json:"mode"` - ID string `json:"id"` - Kind string `json:"kind"` - Spec string `json:"spec"` - Answer string `json:"answer"` - DeleteHome string `json:"delete_home"` - } - if err := json.Unmarshal(input, &in); err != nil { - return "", err - } - if name == tools.PaOverview { - return paOverview(ctx) - } - if name == tools.PaCreate { - // The agent doesn't exist yet, so it can't go through paStore below - // (which requires it to already be registered) — this is the one - // operation that runs before that gate. - return paCreate(ctx, in.Agent, in.Objective) - } - if name == tools.PaBrowserSetup { - return paBrowserSetup(ctx) // agent-independent: gateway-wide broker/Chrome setup - } - if strings.TrimSpace(in.Agent) == "" { - return "", fmt.Errorf("an agent name is required") - } - st, home, err := paStore(ctx, in.Agent) - if err != nil { - return "", err - } - defer st.Close() - switch name { - case tools.PaObjective: - return paObjective(ctx, st, in.Action, in.Text) - case tools.PaPolicy: - return paPolicy(ctx, st, home, in.Agent, in.Action, in.Document, in.Hash) - case tools.PaResource: - return paResource(ctx, st, home, in.Action, in.Type, in.Locator, in.Mode, in.ID) - case tools.PaTrigger: - return paTrigger(ctx, st, in.Action, in.Kind, in.Spec, in.ID) - case tools.PaWake: - return paWake(ctx, st, home, in.Agent) - case tools.PaInbox: - return paInbox(ctx, st, in.Agent) - case tools.PaAnswer: - return paAnswer(ctx, st, home, in.Agent, in.ID, in.Answer) - case tools.PaHistory: - return paHistory(ctx, st) - case tools.PaLifecycle: - return paLifecycle(ctx, in.Agent, in.Action, in.DeleteHome) - case tools.PaDoctor: - return paDoctor(ctx, st, home, in.Agent) - } - return "", fmt.Errorf("unknown personal tool %q", name) -} - -func paOverview(ctx context.Context) (string, error) { - s, err := gwconfig.Load() - if err != nil { - return "", err - } - var b strings.Builder - var names []string - for n, a := range s.Agents { - if a.Kind == "personal" { - names = append(names, n) - } - } - if len(names) == 0 { - return "No Personal Agents yet. Ask what the user wants one to do, then call pa_create.", nil - } - sortStrings(names) - for _, n := range names { - st, _, err := paStore(ctx, n) - if err != nil { - fmt.Fprintf(&b, "%s: error %v\n", n, err) - continue - } - obj, hasObj, _ := st.GetObjective(ctx, "primary") - pol, hasPol, _ := st.ApprovedPolicy(ctx, "primary") - pend, _ := st.PendingInteractions(ctx, n) - status := "no objective" - if hasObj { - status = obj.Status - } - polStr := "no approved policy" - if hasPol { - polStr = fmt.Sprintf("policy v%d", pol.Version) - } - fmt.Fprintf(&b, "%s: %s · %s · %s · %d pending question(s)\n", n, obj.Description, status, polStr, len(pend)) - st.Close() - } - return b.String(), nil -} - -// paCreate registers a new Personal Agent and its objective. Mirrors -// personalCreate (the CLI's `personal create`) — same steps, same order — -// since both are legitimate entry points to the same operation; this is the -// one the cockpit conversation actually uses. -func paCreate(ctx context.Context, name, objective string) (string, error) { - name, objective = strings.TrimSpace(name), strings.TrimSpace(objective) - if name == "" || objective == "" { - return "", fmt.Errorf("agent name and objective are both required") - } - s, err := gwconfig.Load() - if err != nil { - return "", err - } - if s.Agents == nil { - s.Agents = map[string]gwconfig.Agent{} - } - if _, ok := s.Agents[name]; ok { - return "", fmt.Errorf("agent %q already exists", name) - } - s.Agents[name] = gwconfig.Agent{Kind: "personal"} - if err := gwconfig.Save(s); err != nil { - return "", err - } - home, err := gwconfig.AgentHome(name) - if err != nil { - return "", err - } - st, err := personal.Open(ctx, home) - if err != nil { - return "", err - } - defer st.Close() - if err := st.CreateObjective(ctx, personal.Objective{ID: "primary", Description: objective, Status: "draft"}); err != nil { - return "", err - } - _ = personal.WriteConfigMirror(ctx, home, st) - return fmt.Sprintf("Created %s. Consequential work is blocked until a policy is staged and approved — gather what it needs (resources, toolsets, consequence classes, wake cadence), present the plan, then pa_policy stage + approve.", name), nil -} - -func paObjective(ctx context.Context, st *personal.Store, action, text string) (string, error) { - switch strings.ToLower(action) { - case "show": - o, ok, err := st.GetObjective(ctx, "primary") - if err != nil || !ok { - return "no objective", nil - } - return fmt.Sprintf("[%s] %s\nsuccess: %s", o.Status, o.Description, o.SuccessCriteria), nil - case "set": - _, ok, err := st.GetObjective(ctx, "primary") - if err != nil { - return "", err - } - if !ok { - return "", fmt.Errorf("no primary objective — this agent doesn't exist yet; use pa_create, not pa_objective, for a brand-new one") - } - if err := st.SetObjectiveText(ctx, "primary", text); err != nil { - return "", err - } - return "objective updated", nil - } - return "", fmt.Errorf("action must be show or set") -} - -func paPolicy(ctx context.Context, st *personal.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.", nil - } - return fmt.Sprintf("approved policy v%d hash=%s\n%s", p.Version, p.Hash, string(p.Document)), nil - case "stage": - var doc personal.DelegationPolicy - if err := json.Unmarshal([]byte(document), &doc); err != nil { - return "", fmt.Errorf("document is not valid DelegationPolicy JSON: %w", err) - } - canon, h, err := personal.CanonicalPolicy(doc) - if err != nil { - return "", err - } - ver, err := st.NextPolicyVersion(ctx, "primary") - if err != nil { - return "", err - } - if err := st.InsertPolicy(ctx, personal.Policy{ID: "policy-" + h[:8], ObjectiveID: "primary", Version: ver, Document: canon, Hash: h, Status: "draft"}); err != nil { - return "", err - } - // Persist the canonical doc to policies/.json (parity with the CLI). - _ = os.MkdirAll(filepath.Join(home, "policies"), 0o700) - _ = atomicfile.WriteFile(filepath.Join(home, "policies", h+".json"), canon, 0o600) - _ = personal.WriteConfigMirror(ctx, home, st) - return fmt.Sprintf("Draft policy v%d staged (hash %s). Approve with pa_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 - } - _ = st.SetObjectiveStatus(ctx, "primary", "active") - _ = personal.WriteConfigMirror(ctx, home, st) - return fmt.Sprintf("Approved policy %s; %s is now active.", match[:12], agent), nil - } - return "", fmt.Errorf("action must be show, stage, or approve") -} - -func paResource(ctx context.Context, st *personal.Store, home, action, rtype, locator, mode, id string) (string, error) { - switch strings.ToLower(action) { - case "grant": - if rtype == "" { - rtype = "filesystem" // the common case — same inference the CLI's `resources add` uses - } - if mode == "" { - mode = "read" - } - if rtype == "filesystem" { - canon, err := personal.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, personal.Resource{ID: rid, ObjectiveID: "primary", Type: rtype, Locator: locator, AccessMode: mode, AuthorizationSource: "cockpit", Status: "active"}); err != nil { - return "", err - } - _ = personal.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 use 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 - } - _ = personal.WriteConfigMirror(ctx, home, st) - return "revoked " + id, nil - } - return "", fmt.Errorf("action must be grant, list, or revoke") -} - -func paTrigger(ctx context.Context, st *personal.Store, action, kind, spec, id string) (string, error) { - switch strings.ToLower(action) { - case "add": - kindMap := map[string]string{"interval": "interval", "cron": "cron", "one-shot": "one_shot"} - dbKind, ok := kindMap[strings.ToLower(kind)] - if !ok { - return "", fmt.Errorf("kind must be interval, cron, or one-shot") - } - now := time.Now().UTC() - next, err := personal.NextDue(dbKind, spec, now) - if err != nil { - return "", fmt.Errorf("bad spec: %w", err) - } - tid := fmt.Sprintf("trig-%s-%d", dbKind, now.Unix()) - if err := st.CreateTrigger(ctx, personal.Trigger{ID: tid, ObjectiveID: "primary", Kind: dbKind, Spec: spec, NextDueAt: &next}); err != nil { - return "", err - } - return fmt.Sprintf("Trigger %s added; next wake %s.", tid, next.Format(time.RFC3339)), nil - case "list": - trigs, err := st.ListTriggers(ctx) - if err != nil { - return "", err - } - if len(trigs) == 0 { - return "no triggers", nil - } - var b strings.Builder - for _, t := range trigs { - next := "—" - if t.NextDueAt != nil { - next = t.NextDueAt.Format(time.RFC3339) - } - fmt.Fprintf(&b, "%s: %s %q next=%s [%s]\n", t.ID, t.Kind, t.Spec, next, t.Status) - } - return b.String(), nil - case "pause", "resume": - status := "paused" - if strings.ToLower(action) == "resume" { - status = "enabled" - } - if _, err := st.DB().ExecContext(ctx, `UPDATE triggers SET status=?,updated_at=? WHERE id=?`, status, time.Now().UTC().Format(time.RFC3339Nano), id); err != nil { - return "", err - } - return fmt.Sprintf("trigger %s %s", id, status), nil - } - return "", fmt.Errorf("action must be add, list, pause, or resume") -} - -func paWake(ctx context.Context, st *personal.Store, home, agent string) (string, error) { - if _, hasPol, err := st.ApprovedPolicy(ctx, "primary"); err != nil { - return "", err - } else if !hasPol { - return "blocked: no approved policy — stage and approve one first", nil - } - provider.LoadDotEnv() - prov, err := provider.NewFromEnv() - if err != nil { - return "", fmt.Errorf("no model configured: %w", err) - } - ex := &personal.Executive{Store: st, Home: home, AgentID: agent, 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 pa_answer\n", out.InteractionID) - } - return b.String(), nil -} - -func paInbox(ctx context.Context, st *personal.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 paAnswer(ctx context.Context, st *personal.Store, home, agent, id, answer string) (string, error) { - in, ok, err := st.GetInteraction(ctx, id) - if err != nil || !ok { - return "", fmt.Errorf("no pending interaction %q", id) - } - if in.AgentID != agent { - return "", fmt.Errorf("interaction %q belongs to %s", id, in.AgentID) - } - provider.LoadDotEnv() - prov, err := provider.NewFromEnv() - if err != nil { - return "", fmt.Errorf("no model configured: %w", err) - } - ex := &personal.Executive{Store: st, Home: home, AgentID: agent, Runner: llm.NewRunner(prov)} - 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 paHistory(ctx context.Context, st *personal.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\n", a.CreatedAt.Format("15:04:05"), a.Kind, a.Target, a.Status) - } - return b.String(), nil -} - -func paLifecycle(ctx context.Context, agent, action, deleteHome string) (string, error) { - s, err := gwconfig.Load() - if err != nil { - return "", err - } - a, ok := s.Agents[agent] - if !ok || a.Kind != "personal" { - return "", fmt.Errorf("no Personal Agent %q", agent) - } - switch strings.ToLower(action) { - case "pause", "resume", "stop": - st, _, err := paStore(ctx, agent) - if err != nil { - return "", err - } - defer st.Close() - status := map[string]string{"pause": "paused", "resume": "active", "stop": "stopped"}[strings.ToLower(action)] - if err := st.SetObjectiveStatus(ctx, "primary", status); err != nil { - return "", err - } - return fmt.Sprintf("%s: %s", agent, status), nil - case "delete": - delete(s.Agents, agent) - if err := gwconfig.Save(s); err != nil { - return "", err - } - if strings.EqualFold(deleteHome, "true") { - home, _ := gwconfig.AgentHome(agent) - if err := os.RemoveAll(home); err != nil { - return "", err - } - } - return fmt.Sprintf("Removed %s (home deleted=%s).", agent, deleteHome), nil - } - return "", fmt.Errorf("action must be pause, resume, stop, or delete") -} - -func sortStrings(s []string) { - for i := 1; i < len(s); i++ { - for j := i; j > 0 && s[j] < s[j-1]; j-- { - s[j], s[j-1] = s[j-1], s[j] - } - } -} - -func shortHash(h string) string { - if len(h) > 12 { - return h[:12] - } - return h -} - -func sandboxNote() string { - if personal.SandboxAvailable() { - return "hardened (bwrap)" - } - return "no bwrap — generated code runs fail-closed unless explicitly approved" -} - -// paDoctor health-checks one agent: home layout, objective, approved policy, -// generated workspace, sandbox availability, trigger/pending-interaction -// counts. Returns a report string rather than a bool — the cockpit relays -// findings to the user itself, it doesn't need a separate pass/fail signal. -func paDoctor(ctx context.Context, st *personal.Store, home, agent string) (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)) - } - obj, hasObj, _ := st.GetObjective(ctx, "primary") - check("objective", hasObj, obj.Description) - 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 := personal.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()) - trigs, _ := st.ListTriggers(ctx) - pend, _ := st.PendingInteractions(ctx, agent) - fmt.Fprintf(&b, "triggers: %d, pending interactions: %d\n", len(trigs), len(pend)) - return b.String(), nil -} - -// paBrowserSetup checks existing-Chrome delegation prerequisites and attempts -// a real, bounded connection — the same checks `memcode personal browser -// setup` used to run as a separate CLI command, now reachable the same way -// every other Personal Agent operation is: a tool call in the cockpit -// conversation, not a command the user has to know to type. -func paBrowserSetup(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, func() string { - if err != nil { - return "not found on PATH — Node.js is required" - } - return npx - }()) - 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, func() string { - if reachable { - return sock - } - return "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 := mcp.Connect(cctx, map[string]mcp.ServerConfig{ - "chrome-devtools": {Type: "stdio", Command: "npx", Args: []string{"-y", browser.ChromeDevToolsMCPPackage, "--autoConnect"}}, - }, mcp.Options{Version: "0.1.0"}) - 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: Chrome 144+, chrome://inspect/#remote-debugging toggled on, Chrome\n") - b.WriteString("actually running, and click Allow if a dialog appears in Chrome — only they can.\n") - return b.String(), nil - } - fmt.Fprintf(&b, "[ok] connected — %d browser tool(s) available. Existing-Chrome delegation is ready.\n", toolCount) - return b.String(), nil -} diff --git a/cmd/run.go b/cmd/run.go index 01ce45c..359dd35 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -150,7 +150,7 @@ for local gateway development. Never store keys in .memcode.`, client := broker.NewClient(sock) lease, err := client.Acquire(agentID, runID, 10*time.Minute) if err != nil { - return fmt.Errorf("existing-Chrome unavailable: %w — run `memcode personal browser setup`; refusing to fall back to ephemeral Chrome", err) + 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{ diff --git a/internal/agent/runtime/admin.go b/internal/agent/runtime/admin.go index 115f9a5..8b4a878 100644 --- a/internal/agent/runtime/admin.go +++ b/internal/agent/runtime/admin.go @@ -23,75 +23,38 @@ 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 { - 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") - } - return false -} - -// adminTool gates and dispatches an admin tool call to the injected executor. -func (s *Session) adminTool(ctx context.Context, name string, input json.RawMessage) toolResult { - if s.adminExec == nil { - return errResult("admin tools are unavailable in this session") - } - if !adminReadOnly(name, input) { - title := name - if compact := compactAdminInput(input); compact != "" { - title = fmt.Sprintf("%s %s", name, compact) - } - if ok, reason := s.gate(ctx, permissions.Medium, false, ApprovalRequest{ - Title: title, Label: "Gateway change", Risk: permissions.Medium.String(), - }); !ok { - return errResult("denied: " + reason) - } - } - out, err := s.adminExec(ctx, name, input) - if err != nil { - return errResult(err.Error()) - } - return textResult(out) -} - -// personalReadOnly reports whether a pa_* call is a pure read (no approval gate). -func personalReadOnly(name string, input json.RawMessage) bool { switch name { - case tools.PaOverview, tools.PaInbox, tools.PaHistory: + case tools.GwOverview, tools.GwInbox, tools.GwJournal, tools.GwDoctor, tools.GwBrowser: return true } - // show/list sub-actions are reads. var in struct { Action string `json:"action"` } _ = json.Unmarshal(input, &in) a := strings.ToLower(strings.TrimSpace(in.Action)) switch name { - case tools.PaObjective, tools.PaPolicy: + case tools.GwService: + return a == "status" + case tools.GwPolicy: return a == "show" - case tools.PaResource, tools.PaTrigger: + case tools.GwGrant: return a == "list" } return false } -// personalTool gates and dispatches a personal-cockpit tool call. -func (s *Session) personalTool(ctx context.Context, name string, input json.RawMessage) toolResult { +// adminTool gates and dispatches an admin tool call to the injected executor. +func (s *Session) adminTool(ctx context.Context, name string, input json.RawMessage) toolResult { if s.adminExec == nil { - return errResult("personal cockpit is unavailable in this session") + return errResult("admin tools are unavailable in this session") } - if !personalReadOnly(name, input) { + if !adminReadOnly(name, input) { title := name if compact := compactAdminInput(input); compact != "" { title = fmt.Sprintf("%s %s", name, compact) } if ok, reason := s.gate(ctx, permissions.Medium, false, ApprovalRequest{ - Title: title, Label: "Personal Agent change", Risk: permissions.Medium.String(), + Title: title, Label: "Gateway change", Risk: permissions.Medium.String(), }); !ok { return errResult("denied: " + reason) } diff --git a/internal/agent/runtime/exec.go b/internal/agent/runtime/exec.go index 4b3e902..c6dc732 100644 --- a/internal/agent/runtime/exec.go +++ b/internal/agent/runtime/exec.go @@ -313,10 +313,9 @@ 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.PaOverview, tools.PaObjective, tools.PaPolicy, tools.PaResource, tools.PaTrigger, tools.PaWake, tools.PaInbox, tools.PaAnswer, tools.PaHistory, tools.PaLifecycle: - return s.personalTool(ctx, u.Name, u.Input) case tools.GitHub: return s.githubTool(ctx, u.Input) case tools.RunTests: @@ -710,17 +709,6 @@ func reviewTool(name string) bool { // toolDefs returns the tools advertised to the model for the current mode. func (s *Session) toolDefs() []wire.ToolDef { - if s.personalMode { - // Personal cockpit: the pa_* registry plus ask_user. No repo/coding/shell - // tools — management goes through typed, gated pa_* operations. - defs := tools.PersonalDefs() - for _, d := range tools.Defs() { - if d.Name == tools.AskUser { - defs = append(defs, d) - } - } - return defs - } if s.adminMode { // Admin sessions get the admin registry plus a small file surface for // agent homes (instructions, memory, skills): read/edit/search/bash diff --git a/internal/agent/runtime/prompts.go b/internal/agent/runtime/prompts.go index 9c43a01..353845a 100644 --- a/internal/agent/runtime/prompts.go +++ b/internal/agent/runtime/prompts.go @@ -154,9 +154,6 @@ func randomPersonality() string { // chatSpec is the INTERACTIVE session prompt (TUI). func (s *Session) chatSpec(overview string) promptSpec { - if s.personalMode { - return promptSpec{mode: "personal_admin", facts: s.baseFacts()} - } if s.adminMode { return promptSpec{mode: "admin", facts: s.baseFacts()} } diff --git a/internal/agent/runtime/runtime.go b/internal/agent/runtime/runtime.go index 21480e3..ee48779 100644 --- a/internal/agent/runtime/runtime.go +++ b/internal/agent/runtime/runtime.go @@ -106,7 +106,6 @@ type Session struct { browserHeadless bool // gateway/service sessions run Chrome headless (no desktop) noApprover bool // detached job: no human can answer approval prompts adminMode bool // admin session (`memcode admin`): admin tools only, settings doctrine - personalMode bool // personal cockpit (`memcode personal`): pa_* tools only, personal doctrine adminExec AdminExecutor // cmd-injected admin operations (engine never imports the gateway layer) forceEscalate bool // strong-tier agent: pin every request to the strong vendor (balanced tier) forceFrontier bool // long-running (background) agent: pin every request to the FRONTIER tier @@ -368,19 +367,9 @@ func (s *Session) SetAdmin(exec AdminExecutor) { s.adminExec = exec } -// SetPersonal switches this session into the Personal Agents cockpit: pa_* tools -// only (same injected-executor seam as admin), personal doctrine. -func (s *Session) SetPersonal(exec AdminExecutor) { - s.personalMode = true - s.adminExec = exec -} - -// Personal reports whether this is a personal-cockpit session. -func (s *Session) Personal() bool { return s.personalMode } - // Restricted reports whether the session is a restricted management console -// (admin or personal cockpit): a limited slash whitelist, no repo/coding tools. -func (s *Session) Restricted() bool { return s.adminMode || s.personalMode } +// (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 } diff --git a/internal/agent/tools/admin.go b/internal/agent/tools/admin.go index 81f0450..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,20 +65,22 @@ func AdminDefs() []wire.ToolDef { }, { Name: GwAgent, - Description: "Create 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). Optional kind=personal creates a Personal Agent, whose objective and lifecycle must be managed with memcode personal. Bind ordinary 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.", + 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"), - "kind": str("add only: empty for an ordinary agent, or personal; Personal lifecycle is managed with memcode personal"), + "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"), @@ -73,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"), }, @@ -84,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/agent/tools/personal.go b/internal/agent/tools/personal.go deleted file mode 100644 index fb1a007..0000000 --- a/internal/agent/tools/personal.go +++ /dev/null @@ -1,134 +0,0 @@ -package tools - -import "github.com/memcode-ai/memcode/internal/wire" - -// Personal cockpit toolset — the `memcode personal` interactive session's ONLY -// typed operations (plus ask_user). Deterministic management of Personal Agents: -// objectives, policies, resources, triggers, wakes, and pending interactions. -const ( - PaOverview = "pa_overview" // list all Personal Agents with status - PaCreate = "pa_create" // create a new Personal Agent (name + objective) - PaObjective = "pa_objective" // show/set an agent's objective - PaPolicy = "pa_policy" // stage/show/approve delegation policies - PaResource = "pa_resource" // grant/list/revoke resources - PaTrigger = "pa_trigger" // add/list/pause/resume wake triggers - PaWake = "pa_wake" // run one bounded wake now - PaInbox = "pa_inbox" // list pending human interactions - PaAnswer = "pa_answer" // answer a pending interaction - PaHistory = "pa_history" // recent runs + journaled actions - PaLifecycle = "pa_lifecycle" // pause/resume/stop/delete an agent - PaDoctor = "pa_doctor" // health check: home layout, objective, policy, sandbox, triggers, pending interactions - PaBrowserSetup = "pa_browser_setup" // check/connect existing-Chrome delegation prerequisites -) - -// PersonalDefs returns the personal-cockpit tool registry. -func PersonalDefs() []wire.ToolDef { - return []wire.ToolDef{ - { - Name: PaOverview, - Description: "List all Personal Agents: objective, status, approved policy version, pending questions, next wake. Call this first to answer questions about current state.", - InputSchema: obj(map[string]any{}), - }, - { - Name: PaCreate, - Description: "Create a new Personal Agent: a name and its objective (the durable desired outcome). This is the FIRST call for a brand-new agent — pa_objective/pa_resource/pa_policy all require the agent to already exist. Fails if the name is already taken. The agent starts inert (consequential work blocked) until a policy is staged and approved.", - InputSchema: obj(map[string]any{ - "agent": str("new agent name — short, stable, used everywhere else to refer to it"), - "objective": str("the durable desired outcome, e.g. \"Find and track backend engineering roles, keep a shortlist\""), - }, "agent", "objective"), - }, - { - Name: PaObjective, - Description: "Show or change an EXISTING agent's objective (the durable desired outcome + success criteria). action=show or action=set with text. Use pa_create instead for a brand-new agent.", - InputSchema: obj(map[string]any{ - "agent": str("agent name"), - "action": str("show or set"), - "text": str("set only: the objective text"), - }, "agent", "action"), - }, - { - Name: PaPolicy, - Description: "Manage an agent's delegation policy. action=show (approved), action=stage (write a draft from a JSON policy doc in 'document'), action=approve (by hash). Consequential work is 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"), - "hash": str("approve only: the policy hash or prefix"), - }, "agent", "action"), - }, - { - Name: PaResource, - Description: "Grant or revoke a resource. action=grant (locator, optionally type and mode), action=list, action=revoke (id). type defaults to filesystem (mode defaults to read) when omitted — the common case is just a path. Filesystem paths are canonicalized and symlink-resolved.", - 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"), - "mode": str("grant only: read (default), write, or admin"), - "id": str("revoke only: the resource id"), - }, "agent", "action"), - }, - { - Name: PaTrigger, - Description: "Manage wake triggers. action=add (kind: interval|cron|one-shot, spec), action=list, action=pause/resume (id).", - InputSchema: obj(map[string]any{ - "agent": str("agent name"), - "action": str("add, list, pause, or resume"), - "kind": str("add only: interval, cron, or one-shot"), - "spec": str("add only: e.g. 30m, '0 * * * *', or RFC3339"), - "id": str("pause/resume only: the trigger id"), - }, "agent", "action"), - }, - { - Name: PaWake, - Description: "Run one bounded wake for an agent right now. Fails closed if no policy is approved. Returns the run's status and report.", - InputSchema: obj(map[string]any{ - "agent": str("agent name"), - }, "agent"), - }, - { - Name: PaInbox, - Description: "List an agent's pending human interactions (questions it is suspended waiting on).", - InputSchema: obj(map[string]any{ - "agent": str("agent name"), - }, "agent"), - }, - { - Name: PaAnswer, - Description: "Answer a pending interaction, resuming the suspended run with the exact continuation.", - InputSchema: obj(map[string]any{ - "agent": str("agent name"), - "id": str("the interaction id"), - "answer": str("the human's answer"), - }, "agent", "id", "answer"), - }, - { - Name: PaHistory, - Description: "Show an agent's recent runs and journaled consequential actions.", - InputSchema: obj(map[string]any{ - "agent": str("agent name"), - }, "agent"), - }, - { - Name: PaLifecycle, - Description: "Change an agent's lifecycle. action=pause, resume, stop, or delete. delete removes the config entry but keeps the agent home unless delete_home=true.", - InputSchema: obj(map[string]any{ - "agent": str("agent name"), - "action": str("pause, resume, stop, or delete"), - "delete_home": str("delete only: 'true' to also permanently delete the agent home"), - }, "agent", "action"), - }, - { - Name: PaDoctor, - Description: "Health check an agent: home directory layout, objective, approved policy, generated workspace, sandbox availability, trigger count, pending interaction count. Use when something seems wrong or before a first wake.", - InputSchema: obj(map[string]any{ - "agent": str("agent name"), - }, "agent"), - }, - { - Name: PaBrowserSetup, - Description: "Check existing-Chrome delegation prerequisites (npx, the gateway's browser broker) and attempt a real, bounded connection to the user's running Chrome. Call this when a delegate call with a browser toolset fails closed, or when the user asks to set up browser access. Does not click Chrome's own consent dialog — only the user can do that.", - InputSchema: obj(map[string]any{}), - }, - } -} diff --git a/internal/browser/broker/server.go b/internal/browser/broker/server.go index ac6f1cc..cbd9d1d 100644 --- a/internal/browser/broker/server.go +++ b/internal/browser/broker/server.go @@ -16,7 +16,7 @@ import ( // 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 Personal Agent's delegated -// worker running as a standalone `memcode personal run`, not just inside the +// 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) { diff --git a/internal/doctrine/prompts.go b/internal/doctrine/prompts.go index b6b6cab..11f9f09 100644 --- a/internal/doctrine/prompts.go +++ b/internal/doctrine/prompts.go @@ -398,11 +398,11 @@ 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 "personal": - // Domain-general Personal Agent executive. No repo root required — the - // agent operates over granted environment resources, not a checkout. + 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 a Personal Agent's bounded executive advancing one long-lived objective toward its success criteria, using only the authority an approved policy grants. + `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. @@ -429,8 +429,6 @@ Rules you must follow: }, "\n\n") case "admin": base = adminDoctrine - case "personal_admin": - base = personalAdminDoctrine case "cold": // The A/B baseline: deliberately a vanilla tool agent, no doctrine. base = fmt.Sprintf(`You are a coding assistant working in the repository at %s. @@ -668,74 +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.` - -const personalAdminDoctrine = `You are the memcode Personal Agents cockpit — you manage the user's long-lived Personal Agents by conversation in an interactive terminal session. You are not a coding agent; you are the control room for Personal Agents. - -You manage: objectives, delegation policies, resource grants, wake triggers, bounded wakes, pending human interactions (questions agents are suspended on), run history, and agent lifecycle. - -Rules: -- Changes go through the typed pa_* tools, never by hand-editing files, and never by telling the user to run a CLI command — you do the work yourself, right here: pa_overview, pa_create, pa_objective, pa_policy, pa_resource, pa_trigger, pa_wake, pa_inbox, pa_answer, pa_history, pa_lifecycle. (The CLI subcommands under memcode personal exist only for scripts; they are hidden from --help on purpose. Never suggest one to a person you're already talking to — that's you.) -- A brand-new agent starts with pa_create (name + objective), not pa_objective — pa_objective/pa_resource/pa_policy all require the agent to already exist. -- A Personal Agent can only do consequential work after a policy is approved. To enable one: pa_policy action=stage with a DelegationPolicy JSON, then pa_policy action=approve with the returned hash. Explain that approval gates authority. -- Resources are explicit grants. Use pa_resource to grant a filesystem path (canonicalized, symlink-resolved) with read/write/admin mode; revoke to cut access at the next dispatch. -- pa_wake runs one bounded wake now; pa_trigger adds recurring wakes the gateway fires. A wake ends by reporting, scheduling the next wake, or suspending with a question. -- When an agent is suspended waiting for a human, pa_inbox lists the pending question and pa_answer resumes it with the exact continuation. -- Start from reality: call pa_overview before answering questions about current state; never answer from assumption. -- Mutations run through an approval gate the user sees. State the change plainly. -- When a request is ambiguous (which agent, what objective, what spec), use ask_user rather than guessing. -- Stay in scope: for coding tasks point the user at the normal memcode session; for gateway/channel config point them at memcode admin. - -Creating a new Personal Agent is ONE guided setup conversation that ends with -a fully running agent, not a single tool call and not a pile of separate -manual steps the user has to remember to do themselves. The user states an -objective; you do NOT jump straight to pa_objective and leave everything -else for later. Work it like this, out loud, in the chat: - 1. GATHER REQUIREMENTS: from the stated objective, reason about everything - it will actually need to run — - - Resources: which filesystem paths (a resume, a tracking folder), - which toolsets (browser for job-board/email/site work — default - that to the user's OWN existing, already-logged-in Chrome, not a - fresh profile — mcp servers like gmail, 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, e.g. submitting an application or sending a - message). - - Runtime cadence: how this agent actually gets invoked going - forward — a recurring trigger (interval like "every 6h", a cron - spec like "every morning at 8"), a one-shot, or manual-only (no - trigger; the user runs it themselves with pa_wake). This is NOT - optional to think about — an agent with no trigger and no plan to - ever be woken is dead on arrival. - Ask the user anything genuinely unclear (ask_user) rather than - guessing at scope — especially cadence: don't silently pick "every 5 - minutes" or "never" on your own judgment. - 2. PRESENT: lay out the concrete plan in plain language before touching - anything — the objective as you understand it, each resource you - intend to grant and why, each toolset and consequence class you - intend the policy to allow, the wake cadence you intend to set up, and - what stays out of scope. This is the review surface; the user should - be able to read it and know exactly what authority and what standing - schedule they're about to hand over. - 3. APPROVE & APPLY: only once the user confirms (adjusting anything they - push back on) do you actually build it, completely — pa_create, then - pa_resource grants, pa_policy stage + pa_policy approve for the agreed - policy, AND pa_trigger to set up the agreed cadence (or explicitly none, - if manual-only was agreed). Don't leave triggers as a "you can add this - later" footnote when the user was clear about wanting recurring - behavior — set it up now, in this same conversation. Offer to run - pa_wake once immediately if that fits what they asked for. - 4. Never stage-and-approve a policy the user hasn't seen in plain language - first, and never grant a resource or wake cadence "just in case" beyond - what the stated objective actually needs — narrower is correct, the - user can always grant more later. -This applies to a first-time creation; a later change (adding one more -resource to an existing agent, tightening a policy, adjusting its cadence) -can be a direct, single-step pa_resource/pa_policy/pa_trigger call when the -ask is already that specific — the full walkthrough is for the ambiguous -"here's what I want it to do, figure out what it needs" moment, not every -subsequent tweak.` +- 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 0e90115..2ef6235 100644 --- a/internal/gateway/config/config.go +++ b/internal/gateway/config/config.go @@ -96,10 +96,36 @@ 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 { - // Kind selects additive runtime behavior. Empty preserves the ordinary named - // agent behavior. "personal" enables the Personal Agent runtime; its mutable - // state remains in the agent home rather than gateway.yaml. - Kind 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. @@ -406,13 +432,30 @@ func Load() (Settings, error) { // legacy zero values. func (s Settings) Validate() error { for id, agent := range s.Agents { - if agent.Kind != "" && agent.Kind != "personal" { - return fmt.Errorf("agent %q has unknown kind %q", id, agent.Kind) + 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 { diff --git a/internal/gateway/config/config_test.go b/internal/gateway/config/config_test.go index e258d1f..3de8b50 100644 --- a/internal/gateway/config/config_test.go +++ b/internal/gateway/config/config_test.go @@ -92,26 +92,42 @@ func TestAllowed(t *testing.T) { } } -func TestAgentKindCompatibilityAndValidation(t *testing.T) { - legacy := Settings{Agents: map[string]Agent{"ordinary": {Model: "m"}}} - if err := legacy.Validate(); err != nil { - t.Fatalf("legacy empty kind must remain valid: %v", err) +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 got := legacy.Agents["ordinary"].Kind; got != "" { - t.Fatalf("legacy kind = %q, want empty", got) + if a := ordinary.Agents["ordinary"]; a.Autonomous || a.Unattended() || a.Objective != "" { + t.Fatalf("ordinary agent defaulted to autonomy: %+v", a) } - personal := Settings{Agents: map[string]Agent{"executive": {Kind: "personal"}}} - if err := personal.Validate(); err != nil { - t.Fatalf("personal kind rejected: %v", err) + // 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") } - unknown := Settings{Agents: map[string]Agent{"bad": {Kind: "workflow"}}} - if err := unknown.Validate(); err == nil { - t.Fatal("unknown agent kind must be rejected") + 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{}) { diff --git a/internal/gateway/server/autonomy.go b/internal/gateway/server/autonomy.go new file mode 100644 index 0000000..62e8c9d --- /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/channels" + gwconfig "github.com/memcode-ai/memcode/internal/gateway/config" + "github.com/memcode-ai/memcode/internal/llm" + "github.com/memcode-ai/memcode/internal/personal" + "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 *personal.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]*personal.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]*personal.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 = personal.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 := personal.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 := &personal.Executive{Store: st, Home: home, AgentID: agentID, 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/personal.go b/internal/gateway/server/personal.go deleted file mode 100644 index b1bf470..0000000 --- a/internal/gateway/server/personal.go +++ /dev/null @@ -1,157 +0,0 @@ -package server - -import ( - "context" - "fmt" - "io" - "time" - - "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/personal" - "github.com/memcode-ai/memcode/internal/provider" -) - -const personalRoutePrefix = "personal:" - -// personalChannel is the internal wake route for Personal Agents. It has no -// external sender; byName gets a discard entry so Deliver routes and runJob -// handles the executive inline. -const personalChannelName = "personal" - -func hasPersonalAgents(settings gwconfig.Settings) bool { - for _, agent := range settings.Agents { - if agent.Kind == "personal" { - return true - } - } - return false -} - -// personalWakeLoop polls each Personal Agent's durable triggers and enqueues a -// wake for any that are due. Claims are atomic (ClaimDueTrigger), so a fired -// trigger advances its next_due and cannot double-fire across restarts. -// -// It keeps one open *personal.Store per agent for the life of the loop instead -// of opening and closing a connection (full PRAGMA setup + migration check) on -// every 15s 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 is removed from config. -func (r *runtime) personalWakeLoop(ctx context.Context) { - stores := map[string]*personal.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.fireDuePersonalTriggers(ctx, stores) - } -} - -func (r *runtime) fireDuePersonalTriggers(ctx context.Context, stores map[string]*personal.Store) { - settings := r.cfg() - now := time.Now().UTC() - live := map[string]bool{} - for id, agent := range settings.Agents { - if agent.Kind != "personal" { - continue - } - live[id] = true - st := stores[id] - if st == nil { - home, err := gwconfig.AgentHome(id) - if err != nil { - continue - } - st, err = personal.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 trigger. - claimed, ok, err := st.ClaimDueTrigger(ctx, t.ID, now) - if err != nil || !ok { - continue - } - text := fmt.Sprintf("wake for trigger %s (%s)", claimed.ID, claimed.Kind) - if err := r.enqueuePersonalWake(ctx, id, text); err != nil { - fmt.Fprintf(r.out, "gateway: personal wake for %s failed: %v\n", id, err) - } - } - } - // An agent removed (or reconfigured away from kind=personal) 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) enqueuePersonalWake(ctx context.Context, agentID, text string) error { - a, ok := r.cfg().Agents[agentID] - if !ok || a.Kind != "personal" { - return fmt.Errorf("no Personal Agent %q", agentID) - } - return r.Deliver(ctx, channels.Inbound{Channel: personalChannelName, Conversation: agentID, Principal: personalRoutePrefix + agentID, Text: text, Trusted: true, MessageID: fmt.Sprintf("wake-%d", time.Now().UnixNano())}) -} - -// personalSink is the discard reply target for the internal personal channel: -// executive output is journaled in the agent home, so there is nothing to send. -type personalSink struct{ out io.Writer } - -func (personalSink) Name() string { return personalChannelName } -func (s personalSink) Send(ctx context.Context, _ string, ob channels.Outbound) error { - fmt.Fprintf(s.out, "gateway: personal: %s\n", truncate(ob.Text, 120)) - return nil -} - -// runPersonalWake executes one Personal Agent executive wake inline and returns -// its report as the (discarded) reply. Policy-gated: no approved policy → a -// blocked report, never a run. -func (r *runtime) runPersonalWake(ctx context.Context, agentID string) string { - home, err := gwconfig.AgentHome(agentID) - if err != nil { - return "error: " + err.Error() - } - st, err := personal.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 := &personal.Executive{Store: st, Home: home, AgentID: agentID, 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 c2e725c..4043eb5 100644 --- a/internal/gateway/server/scheduler_test.go +++ b/internal/gateway/server/scheduler_test.go @@ -11,15 +11,19 @@ import ( "github.com/memcode-ai/memcode/internal/gateway/state" ) -func TestHasPersonalAgents(t *testing.T) { - if hasPersonalAgents(gwconfig.Settings{}) { - t.Fatal("empty settings reported Personal Agents") +func TestHasAutonomousAgents(t *testing.T) { + if hasAutonomousAgents(gwconfig.Settings{}) { + t.Fatal("empty settings reported autonomous agents") } - if hasPersonalAgents(gwconfig.Settings{Agents: map[string]gwconfig.Agent{"ordinary": {}}}) { - t.Fatal("ordinary agent reported as personal") + if hasAutonomousAgents(gwconfig.Settings{Agents: map[string]gwconfig.Agent{"ordinary": {}}}) { + t.Fatal("ordinary agent reported as autonomous") } - if !hasPersonalAgents(gwconfig.Settings{Agents: map[string]gwconfig.Agent{"executive": {Kind: "personal"}}}) { - t.Fatal("Personal Agent not discovered") + // 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") } } diff --git a/internal/gateway/server/server.go b/internal/gateway/server/server.go index 0190906..44d86fe 100644 --- a/internal/gateway/server/server.go +++ b/internal/gateway/server/server.go @@ -190,19 +190,19 @@ func Run(ctx context.Context, root string, mainStore store.Store, settings gwcon for _, ch := range chs { rt.byName[ch.Name()] = ch } - // Personal Agents get an internal wake route: no external sender, output is - // journaled to the agent home, so register a discard sink so Deliver routes. - // Registered unconditionally (not gated on hasPersonalAgents at boot) because - // byName is built once here and never mutated again — a Personal Agent added - // later via a hot-reloaded config must still have somewhere for its wakes to - // route without requiring a gateway restart. - rt.byName[personalChannelName] = personalSink{out: out} + // 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 && !hasPersonalAgents(settings) { - return fmt.Errorf("no channels or Personal Agents configured — run `memcode gateway setup` or `memcode personal create`") + 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 { - fmt.Fprintln(out, "gateway: running locally for Personal Agents (no external channels configured)") + fmt.Fprintln(out, "gateway: running locally for autonomous agents (no external channels configured)") } for _, ch := range chs { ch := ch @@ -215,10 +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 wake loop, even if no Personal Agents exist at boot: it - // re-reads settings via r.cfg() every tick, so an agent (and trigger) added + // 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.personalWakeLoop(ctx) // durable personal triggers feed the same inbox + go rt.autonomousWakeLoop(ctx) // agent-authored next-wakes feed the same inbox rt.runWorker(ctx) // blocks until ctx is cancelled if rt.sched != nil { @@ -573,15 +573,15 @@ func (r *runtime) runJob(ctx context.Context, it state.Item) { _ = r.gw.MarkDone(ctx, it.Channel, it.MessageID) return } - // Personal Agent wakes run the bounded executive inline (not a detached coding + // 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 == personalChannelName { - report := r.runPersonalWake(ctx, it.Conversation) // conversation = agent id + 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 personal wake for %s failed: %v\n", it.Conversation, err) + fmt.Fprintf(r.out, "gateway: recording agent wake for %s failed: %v\n", it.Conversation, err) return } - r.deliverReply(ctx, it, report) // personalSink discards to the log + r.deliverReply(ctx, it, report) // agentSink discards to the log return } // A gateway-triggered job has no TTY to answer approval prompts → Auto mode. diff --git a/internal/personal/mirror.go b/internal/personal/mirror.go index f671b5b..be5cadf 100644 --- a/internal/personal/mirror.go +++ b/internal/personal/mirror.go @@ -10,14 +10,17 @@ import ( "github.com/memcode-ai/memcode/internal/atomicfile" ) -// WriteConfigMirror regenerates config.yaml — ONE file in the agent's home -// with everything a human decided (objective, policies, resource grants) — -// from the current DB state. This is what makes `ls ~/.memcode/agents//` -// show something a person can actually read, diff, and grep, instead of only -// a personal.db blob reachable through bespoke operations — every OTHER -// piece of memcode config (gateway.yaml, .mcp.json, CLAUDE.md, skills) is a -// plain file; Personal Agents' setup/config surface should be too, and it -// should be ONE file, not several scattered by table. +// 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: @@ -27,16 +30,16 @@ import ( // 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 the -// cockpit can both touch the same agent) — a SQL transaction gives that +// 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 — use pa_history for that. +// file entirely — read it with gw_journal. // -// Called after every mutation to objective/policy/resources (paCreate, -// paResource grant/revoke, paPolicy stage/approve) — best-effort: a mirror -// failure never blocks the underlying DB write, which already succeeded. +// 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"` @@ -52,23 +55,11 @@ func WriteConfigMirror(ctx context.Context, home string, s *Store) error { AccessMode string `yaml:"access_mode"` Status string `yaml:"status"` } - type objectiveView struct { - Description string `yaml:"description"` - SuccessCriteria string `yaml:"success_criteria,omitempty"` - Status string `yaml:"status"` - } cfg := struct { - Objective *objectiveView `yaml:"objective,omitempty"` Policies []policyView `yaml:"policies,omitempty"` Resources []resourceView `yaml:"resources,omitempty"` }{} - if obj, hasObj, err := s.GetObjective(ctx, "primary"); err != nil { - return err - } else if hasObj { - cfg.Objective = &objectiveView{Description: obj.Description, SuccessCriteria: obj.SuccessCriteria, Status: obj.Status} - } - policies, err := s.ListPolicies(ctx, "primary") if err != nil { return err diff --git a/internal/personal/runner_exec.go b/internal/personal/runner_exec.go index 0e9a80c..f138a6d 100644 --- a/internal/personal/runner_exec.go +++ b/internal/personal/runner_exec.go @@ -21,12 +21,17 @@ import ( // 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 - Runner *llm.Runner - Now func() time.Time - MaxSteps int + 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 @@ -151,19 +156,18 @@ func (e *Executive) RunOnce(ctx context.Context) (RunOutcome, error) { } now := e.now().UTC() - obj, ok, err := e.Store.GetObjective(ctx, "primary") - if err != nil || !ok { - return RunOutcome{}, fmt.Errorf("no primary objective") - } - if obj.Status != "active" && obj.Status != "draft" { - return RunOutcome{Status: "blocked", Report: "objective is " + obj.Status}, nil + // 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 you run `memcode personal approve-policy`"}, nil + 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 { @@ -208,7 +212,7 @@ func (e *Executive) loop(ctx context.Context, runID string, policyDoc Delegation out.Status = "completed" for step := 0; step < e.MaxSteps; step++ { resp, err := e.Runner.Complete(ctx, llm.MainLoop, wire.Request{ - Mode: "personal", + Mode: "autonomous", Facts: map[string]string{"state": e.stateSummary(policyDoc)}, Messages: msgs, Tools: tools, @@ -329,23 +333,16 @@ func (e *Executive) allowedTools(p DelegationPolicy) []wire.ToolDef { // 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 { - o, ok, err := e.Store.GetObjective(context.Background(), "primary") - if err != nil || !ok { - return "" - } var b strings.Builder - fmt.Fprintf(&b, "Objective: %s\n", o.Description) - if o.SuccessCriteria != "" { - fmt.Fprintf(&b, "Success criteria: %s\n", o.SuccessCriteria) - } + 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(), o.ID); err == nil && len(subs) > 0 { + 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 facts, err := e.Store.ListFacts(context.Background(), o.ID); err == nil && len(facts) > 0 { + if facts, err := e.Store.ListFacts(context.Background(), "primary"); err == nil && len(facts) > 0 { b.WriteString("Known facts:\n") for _, f := range facts { fmt.Fprintf(&b, " - %s = %s (source %s)\n", f.Key, string(f.Value), f.Source) @@ -509,7 +506,7 @@ func (e *Executive) execTool(ctx context.Context, runID string, p DelegationPoli // 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 `memcode personal browser setup` not completed) — refusing to fall back to ephemeral Chrome") + 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) diff --git a/internal/personal/runner_exec_test.go b/internal/personal/runner_exec_test.go index 392fddf..d8603a2 100644 --- a/internal/personal/runner_exec_test.go +++ b/internal/personal/runner_exec_test.go @@ -38,6 +38,10 @@ func toolUse(id, name string, input any) wire.Block { 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() @@ -50,7 +54,7 @@ func newTestExecutive(t *testing.T, prov provider.ModelProvider) (*Executive, *S 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", Runner: llm.NewRunner(prov)} + ex := &Executive{Store: st, Home: home, AgentID: "tester", Objective: testObjective, Runner: llm.NewRunner(prov)} return ex, st, home } @@ -158,7 +162,7 @@ func TestExecutiveSuspendsAndResumes(t *testing.T) { 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", Runner: llm.NewRunner(prov2)} + 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) @@ -451,7 +455,7 @@ func TestExecutiveDelegatesToWorker(t *testing.T) { {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", Runner: llm.NewRunner(prov2)} + 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) From 199404fe608d61bfb12af1f0c7960d5682a7945f Mon Sep 17 00:00:00 2001 From: Tim Erwin Date: Sun, 30 Aug 2026 16:09:13 +0700 Subject: [PATCH 10/13] autonomy: rename internal/personal, delete the second cron parser, guard both MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidation step 3, finishing the merge. RENAME. internal/personal -> internal/agent/autonomy. The package was never about a "personal" species; it is the machinery an agent uses when running unattended toward an objective. Terminology in comments follows. SECOND CRON PARSER DELETED. NextDue now understands only the kinds an AGENT writes for itself from inside a run (one_shot / next_wake — always a single future instant, e.g. schedule_wake "come back in 45 minutes"). Recurring cadence a human configures is not a trigger at all: it is an ordinary gateway schedule delivering to agent:, validated once by gwconfig. With recurring kinds gone, ClaimDueTrigger's reschedule branch was unreachable, so firing a wake now simply completes it — and the atomic `last_fired_at IS ?` claim still keeps two gateway processes from double-firing. GUARDS (internal/guard/singletons_test.go). The duplication this branch kept producing was not a one-off: fixes landed on one path and not the other, repeatedly. Four invariants now fail the build instead: - TestSingleCronParser — only internal/gateway parses cron. (This one caught a real leftover while being written: the autonomy package was still importing robfig/cron, which is what prompted the deletion above.) - TestSingleSuspensionImplementation — no hand-rolled continuation formats outside internal/agent/continuation. - TestNoSecondCockpit — no SetPersonal/personalMode/pa_* anywhere. - TestNoAgentKind — autonomy is orthogonal settings, never a kind discriminator; a "kind" field is what made Personal a separate species. The guards deliberately skip vendored forks, .memcode session snapshots, and desktop/node_modules (the same symlink that breaks the two pre-existing go-list guard failures). Store tests updated: the trigger test now exercises a real self-scheduled wake and asserts it completes rather than reschedules, and NextDue's test asserts that interval/cron are REJECTED — accepting them again would rebuild the second scheduler. --- cmd/admin_autonomy.go | 46 +++---- cmd/run.go | 2 +- .../{personal => agent/autonomy}/action.go | 2 +- .../autonomy}/action_test.go | 2 +- internal/{personal => agent/autonomy}/crud.go | 2 +- .../autonomy}/delegation.go | 2 +- .../autonomy}/delegation_test.go | 2 +- .../autonomy}/environment.go | 2 +- .../{personal => agent/autonomy}/executive.go | 2 +- .../autonomy}/executive_test.go | 2 +- .../{personal => agent/autonomy}/facts.go | 2 +- .../{personal => agent/autonomy}/generated.go | 2 +- .../autonomy}/interactions.go | 2 +- .../autonomy}/migrations/002_interactions.sql | 0 .../{personal => agent/autonomy}/mirror.go | 4 +- .../{personal => agent/autonomy}/model.go | 4 +- .../{personal => agent/autonomy}/pacing.go | 2 +- .../autonomy}/pacing_test.go | 2 +- .../{personal => agent/autonomy}/policy.go | 2 +- .../autonomy}/policy_test.go | 2 +- .../{personal => agent/autonomy}/resources.go | 2 +- .../autonomy}/resources_test.go | 2 +- .../{personal => agent/autonomy}/runner.go | 2 +- .../autonomy}/runner_exec.go | 4 +- .../autonomy}/runner_exec_test.go | 2 +- .../autonomy}/runner_test.go | 2 +- .../{personal => agent/autonomy}/scheduler.go | 47 +++---- .../{personal => agent/autonomy}/schema.sql | 0 .../{personal => agent/autonomy}/store.go | 6 +- .../autonomy}/store_test.go | 23 +++- internal/browser/broker/server.go | 2 +- internal/browser/controller.go | 2 +- internal/gateway/server/autonomy.go | 14 +- internal/gateway/server/server.go | 8 +- internal/guard/singletons_test.go | 130 ++++++++++++++++++ internal/jobs/jobs.go | 2 +- 36 files changed, 236 insertions(+), 98 deletions(-) rename internal/{personal => agent/autonomy}/action.go (99%) rename internal/{personal => agent/autonomy}/action_test.go (98%) rename internal/{personal => agent/autonomy}/crud.go (99%) rename internal/{personal => agent/autonomy}/delegation.go (99%) rename internal/{personal => agent/autonomy}/delegation_test.go (98%) rename internal/{personal => agent/autonomy}/environment.go (94%) rename internal/{personal => agent/autonomy}/executive.go (99%) rename internal/{personal => agent/autonomy}/executive_test.go (99%) rename internal/{personal => agent/autonomy}/facts.go (95%) rename internal/{personal => agent/autonomy}/generated.go (99%) rename internal/{personal => agent/autonomy}/interactions.go (99%) rename internal/{personal => agent/autonomy}/migrations/002_interactions.sql (100%) rename internal/{personal => agent/autonomy}/mirror.go (97%) rename internal/{personal => agent/autonomy}/model.go (98%) rename internal/{personal => agent/autonomy}/pacing.go (98%) rename internal/{personal => agent/autonomy}/pacing_test.go (98%) rename internal/{personal => agent/autonomy}/policy.go (99%) rename internal/{personal => agent/autonomy}/policy_test.go (99%) rename internal/{personal => agent/autonomy}/resources.go (99%) rename internal/{personal => agent/autonomy}/resources_test.go (99%) rename internal/{personal => agent/autonomy}/runner.go (99%) rename internal/{personal => agent/autonomy}/runner_exec.go (99%) rename internal/{personal => agent/autonomy}/runner_exec_test.go (99%) rename internal/{personal => agent/autonomy}/runner_test.go (99%) rename internal/{personal => agent/autonomy}/scheduler.go (76%) rename internal/{personal => agent/autonomy}/schema.sql (100%) rename internal/{personal => agent/autonomy}/store.go (98%) rename internal/{personal => agent/autonomy}/store_test.go (84%) create mode 100644 internal/guard/singletons_test.go diff --git a/cmd/admin_autonomy.go b/cmd/admin_autonomy.go index 5837a0c..f839b86 100644 --- a/cmd/admin_autonomy.go +++ b/cmd/admin_autonomy.go @@ -20,19 +20,19 @@ import ( "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/personal" "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) (*personal.Store, string, gwconfig.Agent, error) { +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 @@ -45,14 +45,14 @@ func agentStore(ctx context.Context, agent string) (*personal.Store, string, gwc if err != nil { return nil, "", gwconfig.Agent{}, err } - st, err := personal.Open(ctx, home) + 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 *personal.Store, home, agent, action, document, hash string) (string, error) { +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") @@ -64,11 +64,11 @@ func gwPolicy(ctx context.Context, st *personal.Store, home, agent, action, docu } return fmt.Sprintf("approved policy v%d hash=%s\n%s", p.Version, p.Hash, string(p.Document)), nil case "stage": - var doc personal.DelegationPolicy + 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 := personal.CanonicalPolicy(doc) + canon, h, err := autonomy.CanonicalPolicy(doc) if err != nil { return "", err } @@ -76,14 +76,14 @@ func gwPolicy(ctx context.Context, st *personal.Store, home, agent, action, docu if err != nil { return "", err } - if err := st.InsertPolicy(ctx, personal.Policy{ID: "policy-" + h[:8], ObjectiveID: "primary", Version: ver, Document: canon, Hash: h, Status: "draft"}); err != nil { + 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) - _ = personal.WriteConfigMirror(ctx, home, st) + _ = 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") @@ -103,13 +103,13 @@ func gwPolicy(ctx context.Context, st *personal.Store, home, agent, action, docu if err := st.ApprovePolicy(ctx, match); err != nil { return "", err } - _ = personal.WriteConfigMirror(ctx, home, st) + _ = 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 *personal.Store, home, action, rtype, locator, mode, id string) (string, error) { +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 == "" { @@ -119,17 +119,17 @@ func gwGrant(ctx context.Context, st *personal.Store, home, action, rtype, locat mode = "read" } if rtype == "filesystem" { - canon, err := personal.CanonicalFilesystemGrant(locator) + 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, personal.Resource{ID: rid, ObjectiveID: "primary", Type: rtype, Locator: locator, AccessMode: mode, AuthorizationSource: "admin", Status: "active"}); err != nil { + 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 } - _ = personal.WriteConfigMirror(ctx, home, st) + _ = 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") @@ -148,7 +148,7 @@ func gwGrant(ctx context.Context, st *personal.Store, home, action, rtype, locat if err := st.SetResourceStatus(ctx, id, "revoked"); err != nil { return "", err } - _ = personal.WriteConfigMirror(ctx, home, st) + _ = autonomy.WriteConfigMirror(ctx, home, st) return "revoked " + id + " (effective at the next dispatch)", nil } return "", fmt.Errorf("action must be grant, list, or revoke") @@ -157,7 +157,7 @@ func gwGrant(ctx context.Context, st *personal.Store, home, action, rtype, locat // 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 *personal.Store, home, agent string, cfg gwconfig.Agent) (string, error) { +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) } @@ -171,7 +171,7 @@ func gwWake(ctx context.Context, st *personal.Store, home, agent string, cfg gwc if err != nil { return "", fmt.Errorf("no model configured: %w", err) } - ex := &personal.Executive{Store: st, Home: home, AgentID: agent, Objective: cfg.Objective, Runner: llm.NewRunner(prov)} + 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 @@ -187,7 +187,7 @@ func gwWake(ctx context.Context, st *personal.Store, home, agent string, cfg gwc return b.String(), nil } -func gwInbox(ctx context.Context, st *personal.Store, agent string) (string, error) { +func gwInbox(ctx context.Context, st *autonomy.Store, agent string) (string, error) { inter, err := st.PendingInteractions(ctx, agent) if err != nil { return "", err @@ -202,7 +202,7 @@ func gwInbox(ctx context.Context, st *personal.Store, agent string) (string, err return b.String(), nil } -func gwAnswer(ctx context.Context, st *personal.Store, home, agent, id, answer string, cfg gwconfig.Agent) (string, error) { +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) @@ -218,7 +218,7 @@ func gwAnswer(ctx context.Context, st *personal.Store, home, agent, id, answer s if err != nil { return "", fmt.Errorf("no model configured: %w", err) } - ex := &personal.Executive{Store: st, Home: home, AgentID: agent, Objective: cfg.Objective, Runner: llm.NewRunner(prov)} + 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) @@ -231,7 +231,7 @@ func gwAnswer(ctx context.Context, st *personal.Store, home, agent, id, answer s return fmt.Sprintf("answered %s; run %s → %s. %s", id, in.RunID, out.Status, out.Report), nil } -func gwJournal(ctx context.Context, st *personal.Store) (string, error) { +func gwJournal(ctx context.Context, st *autonomy.Store) (string, error) { runs, err := st.ListRuns(ctx, "primary", 10) if err != nil { return "", err @@ -249,7 +249,7 @@ func gwJournal(ctx context.Context, st *personal.Store) (string, error) { return b.String(), nil } -func gwDoctor(ctx context.Context, st *personal.Store, home, agent string, cfg gwconfig.Agent) (string, error) { +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" @@ -277,7 +277,7 @@ func gwDoctor(ctx context.Context, st *personal.Store, home, agent string, cfg g } return "none — consequential work blocked" }()) - if _, err := personal.InitializeGeneratedWorkspace(home); err != nil { + if _, err := autonomy.InitializeGeneratedWorkspace(home); err != nil { check("generated workspace", false, err.Error()) } else { check("generated workspace", true, "git initialized") @@ -349,7 +349,7 @@ func shortHash(h string) string { } func sandboxNote() string { - if personal.SandboxAvailable() { + if autonomy.SandboxAvailable() { return "hardened (bwrap)" } return "no bwrap — generated code runs fail-closed unless explicitly approved" diff --git a/cmd/run.go b/cmd/run.go index 359dd35..71ddbb7 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -132,7 +132,7 @@ for local gateway development. Never store keys in .memcode.`, sess.SetBrowserEnabled(true) defer sess.CloseBrowser() // tear down Chrome when the one-shot session ends } - // --browser-session existing_chrome: this run is a Personal Agent's + // --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 diff --git a/internal/personal/action.go b/internal/agent/autonomy/action.go similarity index 99% rename from internal/personal/action.go rename to internal/agent/autonomy/action.go index 7e0761a..c3e48e9 100644 --- a/internal/personal/action.go +++ b/internal/agent/autonomy/action.go @@ -1,4 +1,4 @@ -package personal +package autonomy import ( "context" diff --git a/internal/personal/action_test.go b/internal/agent/autonomy/action_test.go similarity index 98% rename from internal/personal/action_test.go rename to internal/agent/autonomy/action_test.go index b1a6f2b..ce6a61d 100644 --- a/internal/personal/action_test.go +++ b/internal/agent/autonomy/action_test.go @@ -1,4 +1,4 @@ -package personal +package autonomy import ( "context" diff --git a/internal/personal/crud.go b/internal/agent/autonomy/crud.go similarity index 99% rename from internal/personal/crud.go rename to internal/agent/autonomy/crud.go index 3e29ea9..df09c79 100644 --- a/internal/personal/crud.go +++ b/internal/agent/autonomy/crud.go @@ -1,4 +1,4 @@ -package personal +package autonomy import ( "context" diff --git a/internal/personal/delegation.go b/internal/agent/autonomy/delegation.go similarity index 99% rename from internal/personal/delegation.go rename to internal/agent/autonomy/delegation.go index c35e8e5..ab36f39 100644 --- a/internal/personal/delegation.go +++ b/internal/agent/autonomy/delegation.go @@ -1,4 +1,4 @@ -package personal +package autonomy import ( "encoding/json" diff --git a/internal/personal/delegation_test.go b/internal/agent/autonomy/delegation_test.go similarity index 98% rename from internal/personal/delegation_test.go rename to internal/agent/autonomy/delegation_test.go index 0918228..4619937 100644 --- a/internal/personal/delegation_test.go +++ b/internal/agent/autonomy/delegation_test.go @@ -1,4 +1,4 @@ -package personal +package autonomy import ( "os" diff --git a/internal/personal/environment.go b/internal/agent/autonomy/environment.go similarity index 94% rename from internal/personal/environment.go rename to internal/agent/autonomy/environment.go index a226118..db8a673 100644 --- a/internal/personal/environment.go +++ b/internal/agent/autonomy/environment.go @@ -1,4 +1,4 @@ -package personal +package autonomy import "encoding/json" diff --git a/internal/personal/executive.go b/internal/agent/autonomy/executive.go similarity index 99% rename from internal/personal/executive.go rename to internal/agent/autonomy/executive.go index c000487..e85845f 100644 --- a/internal/personal/executive.go +++ b/internal/agent/autonomy/executive.go @@ -1,4 +1,4 @@ -package personal +package autonomy import ( "fmt" diff --git a/internal/personal/executive_test.go b/internal/agent/autonomy/executive_test.go similarity index 99% rename from internal/personal/executive_test.go rename to internal/agent/autonomy/executive_test.go index 60dd54b..a1cdc21 100644 --- a/internal/personal/executive_test.go +++ b/internal/agent/autonomy/executive_test.go @@ -1,4 +1,4 @@ -package personal +package autonomy import ( "testing" diff --git a/internal/personal/facts.go b/internal/agent/autonomy/facts.go similarity index 95% rename from internal/personal/facts.go rename to internal/agent/autonomy/facts.go index 948fc3d..ada6206 100644 --- a/internal/personal/facts.go +++ b/internal/agent/autonomy/facts.go @@ -1,4 +1,4 @@ -package personal +package autonomy import "encoding/json" diff --git a/internal/personal/generated.go b/internal/agent/autonomy/generated.go similarity index 99% rename from internal/personal/generated.go rename to internal/agent/autonomy/generated.go index 8846f15..19a37e2 100644 --- a/internal/personal/generated.go +++ b/internal/agent/autonomy/generated.go @@ -1,4 +1,4 @@ -package personal +package autonomy import ( "fmt" diff --git a/internal/personal/interactions.go b/internal/agent/autonomy/interactions.go similarity index 99% rename from internal/personal/interactions.go rename to internal/agent/autonomy/interactions.go index 13c8dc4..8d57679 100644 --- a/internal/personal/interactions.go +++ b/internal/agent/autonomy/interactions.go @@ -1,4 +1,4 @@ -package personal +package autonomy import ( "context" diff --git a/internal/personal/migrations/002_interactions.sql b/internal/agent/autonomy/migrations/002_interactions.sql similarity index 100% rename from internal/personal/migrations/002_interactions.sql rename to internal/agent/autonomy/migrations/002_interactions.sql diff --git a/internal/personal/mirror.go b/internal/agent/autonomy/mirror.go similarity index 97% rename from internal/personal/mirror.go rename to internal/agent/autonomy/mirror.go index be5cadf..6116673 100644 --- a/internal/personal/mirror.go +++ b/internal/agent/autonomy/mirror.go @@ -1,4 +1,4 @@ -package personal +package autonomy import ( "context" @@ -25,7 +25,7 @@ import ( // 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 Personal Agent runs unsupervised, so "the document a +// 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. diff --git a/internal/personal/model.go b/internal/agent/autonomy/model.go similarity index 98% rename from internal/personal/model.go rename to internal/agent/autonomy/model.go index 8b4a110..77d487a 100644 --- a/internal/personal/model.go +++ b/internal/agent/autonomy/model.go @@ -1,6 +1,6 @@ // Package personal implements the domain-general durable state and runtime -// primitives for Personal Agents. -package personal +// primitives for autonomous agents. +package autonomy import ( "encoding/json" diff --git a/internal/personal/pacing.go b/internal/agent/autonomy/pacing.go similarity index 98% rename from internal/personal/pacing.go rename to internal/agent/autonomy/pacing.go index 515c368..99db0d1 100644 --- a/internal/personal/pacing.go +++ b/internal/agent/autonomy/pacing.go @@ -1,4 +1,4 @@ -package personal +package autonomy import ( "math/rand" diff --git a/internal/personal/pacing_test.go b/internal/agent/autonomy/pacing_test.go similarity index 98% rename from internal/personal/pacing_test.go rename to internal/agent/autonomy/pacing_test.go index c02b137..39b6df4 100644 --- a/internal/personal/pacing_test.go +++ b/internal/agent/autonomy/pacing_test.go @@ -1,4 +1,4 @@ -package personal +package autonomy import ( "testing" diff --git a/internal/personal/policy.go b/internal/agent/autonomy/policy.go similarity index 99% rename from internal/personal/policy.go rename to internal/agent/autonomy/policy.go index ef45ff8..882c7c6 100644 --- a/internal/personal/policy.go +++ b/internal/agent/autonomy/policy.go @@ -1,4 +1,4 @@ -package personal +package autonomy import ( "bytes" diff --git a/internal/personal/policy_test.go b/internal/agent/autonomy/policy_test.go similarity index 99% rename from internal/personal/policy_test.go rename to internal/agent/autonomy/policy_test.go index c1080de..2f6aa6b 100644 --- a/internal/personal/policy_test.go +++ b/internal/agent/autonomy/policy_test.go @@ -1,4 +1,4 @@ -package personal +package autonomy import ( "testing" diff --git a/internal/personal/resources.go b/internal/agent/autonomy/resources.go similarity index 99% rename from internal/personal/resources.go rename to internal/agent/autonomy/resources.go index 9335876..af5af00 100644 --- a/internal/personal/resources.go +++ b/internal/agent/autonomy/resources.go @@ -1,4 +1,4 @@ -package personal +package autonomy import ( "os" diff --git a/internal/personal/resources_test.go b/internal/agent/autonomy/resources_test.go similarity index 99% rename from internal/personal/resources_test.go rename to internal/agent/autonomy/resources_test.go index 65295ac..ff67e11 100644 --- a/internal/personal/resources_test.go +++ b/internal/agent/autonomy/resources_test.go @@ -1,4 +1,4 @@ -package personal +package autonomy import ( "os" diff --git a/internal/personal/runner.go b/internal/agent/autonomy/runner.go similarity index 99% rename from internal/personal/runner.go rename to internal/agent/autonomy/runner.go index 6cd98b2..2cccffe 100644 --- a/internal/personal/runner.go +++ b/internal/agent/autonomy/runner.go @@ -1,4 +1,4 @@ -package personal +package autonomy import ( "bytes" diff --git a/internal/personal/runner_exec.go b/internal/agent/autonomy/runner_exec.go similarity index 99% rename from internal/personal/runner_exec.go rename to internal/agent/autonomy/runner_exec.go index f138a6d..489b62f 100644 --- a/internal/personal/runner_exec.go +++ b/internal/agent/autonomy/runner_exec.go @@ -1,4 +1,4 @@ -package personal +package autonomy import ( "context" @@ -16,7 +16,7 @@ import ( "github.com/memcode-ai/memcode/internal/wire" ) -// Executive is one Personal Agent's bounded decision loop. Each RunOnce is a +// 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. diff --git a/internal/personal/runner_exec_test.go b/internal/agent/autonomy/runner_exec_test.go similarity index 99% rename from internal/personal/runner_exec_test.go rename to internal/agent/autonomy/runner_exec_test.go index d8603a2..72ea0fd 100644 --- a/internal/personal/runner_exec_test.go +++ b/internal/agent/autonomy/runner_exec_test.go @@ -1,4 +1,4 @@ -package personal +package autonomy import ( "context" diff --git a/internal/personal/runner_test.go b/internal/agent/autonomy/runner_test.go similarity index 99% rename from internal/personal/runner_test.go rename to internal/agent/autonomy/runner_test.go index 53890fd..6ce4a14 100644 --- a/internal/personal/runner_test.go +++ b/internal/agent/autonomy/runner_test.go @@ -1,4 +1,4 @@ -package personal +package autonomy import ( "context" diff --git a/internal/personal/scheduler.go b/internal/agent/autonomy/scheduler.go similarity index 76% rename from internal/personal/scheduler.go rename to internal/agent/autonomy/scheduler.go index 922a652..a5d88c0 100644 --- a/internal/personal/scheduler.go +++ b/internal/agent/autonomy/scheduler.go @@ -1,12 +1,10 @@ -package personal +package autonomy import ( "context" "database/sql" "fmt" "time" - - "github.com/robfig/cron/v3" ) type MissedRunPolicy string @@ -17,26 +15,26 @@ const ( 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 "interval": - d, err := time.ParseDuration(spec) - if err != nil || d <= 0 { - return time.Time{}, fmt.Errorf("invalid interval %q", spec) - } - return after.Add(d), nil - case "cron": - sch, err := cron.ParseStandard(spec) - if err != nil { - return time.Time{}, err - } - return sch.Next(after), nil case "one_shot", "next_wake": return time.Parse(time.RFC3339, spec) default: - return time.Time{}, fmt.Errorf("unknown trigger kind %q", kind) + 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) } } @@ -135,18 +133,13 @@ func (s *Store) ClaimDueTrigger(ctx context.Context, id string, now time.Time) ( 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() - var newNext any - if t.Kind != "one_shot" && t.Kind != "next_wake" { - n, e := NextDue(t.Kind, t.Spec, fired) - if e != nil { - return Trigger{}, false, e - } - newNext = stamp(n) - } else { - t.Status = "completed" - } - res, err := tx.ExecContext(ctx, `UPDATE triggers SET status=?,last_fired_at=?,next_due_at=?,updated_at=? WHERE id=? AND last_fired_at IS ?`, t.Status, stamp(fired), newNext, stamp(fired), id, nullSQL(last)) + 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 } diff --git a/internal/personal/schema.sql b/internal/agent/autonomy/schema.sql similarity index 100% rename from internal/personal/schema.sql rename to internal/agent/autonomy/schema.sql diff --git a/internal/personal/store.go b/internal/agent/autonomy/store.go similarity index 98% rename from internal/personal/store.go rename to internal/agent/autonomy/store.go index 473c9e8..efa4e60 100644 --- a/internal/personal/store.go +++ b/internal/agent/autonomy/store.go @@ -1,4 +1,4 @@ -package personal +package autonomy import ( "context" @@ -39,7 +39,7 @@ func InitializeHome(home string) error { func Open(ctx context.Context, home string) (*Store, error) { if err := InitializeHome(home); err != nil { - return nil, fmt.Errorf("initialize Personal Agent home: %w", err) + return nil, fmt.Errorf("initialize agent home: %w", err) } path := filepath.Join(home, "personal.db") db, err := sql.Open("sqlite", path) @@ -77,7 +77,7 @@ func migrate(ctx context.Context, db *sql.DB) error { } if err != nil { tx.Rollback() - return fmt.Errorf("applying Personal Agent migration %d: %w", i+1, err) + return fmt.Errorf("applying autonomous agent migration %d: %w", i+1, err) } if err := tx.Commit(); err != nil { return err diff --git a/internal/personal/store_test.go b/internal/agent/autonomy/store_test.go similarity index 84% rename from internal/personal/store_test.go rename to internal/agent/autonomy/store_test.go index 6cf961b..2accc7b 100644 --- a/internal/personal/store_test.go +++ b/internal/agent/autonomy/store_test.go @@ -1,4 +1,4 @@ -package personal +package autonomy import ( "context" @@ -94,29 +94,44 @@ func TestPersistentTriggerClaim(t *testing.T) { defer s.Close() now := time.Now().UTC().Truncate(time.Second) due := now.Add(-time.Minute) - if err := s.CreateTrigger(ctx, Trigger{ID: "t1", ObjectiveID: "o1", Kind: "interval", Spec: "5m", NextDueAt: &due}); err != nil { + // 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 || triggers[0].NextDueAt == nil || !triggers[0].NextDueAt.After(now) { + 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", ""}, {"interval", "5m"}, {"cron", "0 * * * *"}, {"one_shot", "2026-08-30T13:00:00Z"}, {"next_wake", "2026-08-30T13:00:00Z"}} { + 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) { diff --git a/internal/browser/broker/server.go b/internal/browser/broker/server.go index cbd9d1d..2932081 100644 --- a/internal/browser/broker/server.go +++ b/internal/browser/broker/server.go @@ -15,7 +15,7 @@ import ( // 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 Personal Agent's delegated +// 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. diff --git a/internal/browser/controller.go b/internal/browser/controller.go index 10ccc0c..6acd146 100644 --- a/internal/browser/controller.go +++ b/internal/browser/controller.go @@ -3,7 +3,7 @@ package browser import "context" // Controller is the stable browser boundary shared by ephemeral and brokered -// backends. Calls remain typed; Personal Agents never receive raw MCP access. +// backends. Calls remain typed; autonomous agents never receive raw MCP access. type Controller interface { Close() error Navigate(context.Context, string) error diff --git a/internal/gateway/server/autonomy.go b/internal/gateway/server/autonomy.go index 62e8c9d..599c73e 100644 --- a/internal/gateway/server/autonomy.go +++ b/internal/gateway/server/autonomy.go @@ -6,10 +6,10 @@ import ( "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/personal" "github.com/memcode-ai/memcode/internal/provider" ) @@ -44,14 +44,14 @@ func hasAutonomousAgents(settings gwconfig.Settings) bool { // 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 *personal.Store per agent for the life of the loop instead +// 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]*personal.Store{} + stores := map[string]*autonomy.Store{} defer func() { for _, st := range stores { st.Close() @@ -69,7 +69,7 @@ func (r *runtime) autonomousWakeLoop(ctx context.Context) { } } -func (r *runtime) fireDueSelfWakes(ctx context.Context, stores map[string]*personal.Store) { +func (r *runtime) fireDueSelfWakes(ctx context.Context, stores map[string]*autonomy.Store) { settings := r.cfg() now := time.Now().UTC() live := map[string]bool{} @@ -89,7 +89,7 @@ func (r *runtime) fireDueSelfWakes(ctx context.Context, stores map[string]*perso if err != nil { continue } - st, err = personal.Open(ctx, home) + st, err = autonomy.Open(ctx, home) if err != nil { continue } @@ -155,7 +155,7 @@ func (r *runtime) runAutonomousWake(ctx context.Context, agentID string) string if err != nil { return "error: " + err.Error() } - st, err := personal.Open(ctx, home) + st, err := autonomy.Open(ctx, home) if err != nil { return "error: " + err.Error() } @@ -172,7 +172,7 @@ func (r *runtime) runAutonomousWake(ctx context.Context, agentID string) string if err != nil { return "error: no model configured: " + err.Error() } - ex := &personal.Executive{Store: st, Home: home, AgentID: agentID, Runner: llm.NewRunner(prov)} + 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() diff --git a/internal/gateway/server/server.go b/internal/gateway/server/server.go index 44d86fe..eea5a75 100644 --- a/internal/gateway/server/server.go +++ b/internal/gateway/server/server.go @@ -82,7 +82,7 @@ type runtime struct { // browserBroker arbitrates exclusive mutation rights over the user's // existing (already-running, already-logged-in) Chrome, so at most one - // delegated Personal Agent worker drives it at a time. It is a SINGLE + // 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 @@ -167,9 +167,9 @@ func Run(ctx context.Context, root string, mainStore store.Store, settings gwcon browserBroker: broker.New(), } // Existing-Chrome coordination socket: started unconditionally (cheap — a - // local listener) so it's there the moment a Personal Agent's delegate - // call needs it, without requiring a gateway restart after `memcode - // personal browser setup`. Its failure is non-fatal to the gateway as 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. diff --git a/internal/guard/singletons_test.go b/internal/guard/singletons_test.go new file mode 100644 index 0000000..8455747 --- /dev/null +++ b/internal/guard/singletons_test.go @@ -0,0 +1,130 @@ +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. +func TestNoAgentKind(t *testing.T) { + for path, src := range goFiles(t, false) { + if strings.Contains(src, `Kind: "personal"`) || strings.Contains(src, `kind == "personal"`) || strings.Contains(src, `Kind == "personal"`) { + t.Errorf("%s still discriminates on an agent kind — use Agent.Autonomous / Agent.Objective", path) + } + } +} diff --git a/internal/jobs/jobs.go b/internal/jobs/jobs.go index 7b6a354..0f8fd9d 100644 --- a/internal/jobs/jobs.go +++ b/internal/jobs/jobs.go @@ -177,7 +177,7 @@ func SpawnWithSpec(spec SpawnSpec) (Job, error) { // 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 - // Personal Agent's delegate tool) that hands this spec a narrower toolset + // 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 { From 0125cc06ef7a5089f70d197528c15cf73f7abd1a Mon Sep 17 00:00:00 2001 From: Tim Erwin Date: Sun, 30 Aug 2026 16:14:23 +0700 Subject: [PATCH 11/13] autonomy: facts table -> memory.md, and get the job link off it first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidation step 4. An agent had two unrelated places to put what it knew: a structured `facts` table only reachable through bespoke tools, and the memory.md every memcode agent already has and the runtime already injects. One of those a human can read. Collapse to it. BLOCKING PREREQUISITE, done first. delegate/check_delegate were using the facts table as a keyed index — writing "delegation." so a later wake could find the action to close out. Its own comment admitted why ("facts are the only durable log delegate can write to without a schema migration"). This is that migration: actions gain a job_id column plus LinkActionJob / ActionForJob, and the link now lives on the action it actually describes. Removing facts before this would have broken check_delegate silently. note_fact becomes `remember`: one plain sentence appended to memory.md, read back into every wake's state summary. Append-only and deduplicated, because every line replays into the model on each future wake — a re-learned fact must not grow the file forever. Deleted with it: environment.go (EnvironmentModel/StructuredFact, referenced nowhere but its own test) and UsableForExternalRepresentation. The tradeoff is deliberate and documented in memory.go rather than left implicit. Prose cannot distinguish "you told me this" from "I inferred it from your resume" from "a website said so", nor mark a claim 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. It is cheap to drop TODAY only because the gate was never wired — nothing read Confirmed to gate anything; the sole caller of UsableForExternalRepresentation was its own test. When external representation needs provenance, it should come back as its own machine-checkable thing in the store alongside policies and the action journal, NOT by reviving this table. --- internal/agent/autonomy/action.go | 19 +++++ internal/agent/autonomy/crud.go | 43 +--------- internal/agent/autonomy/environment.go | 15 ---- internal/agent/autonomy/facts.go | 17 ---- internal/agent/autonomy/memory.go | 69 +++++++++++++++ internal/agent/autonomy/memory_test.go | 50 +++++++++++ .../autonomy/migrations/003_action_job_id.sql | 8 ++ internal/agent/autonomy/model.go | 15 ++-- internal/agent/autonomy/resources_test.go | 11 --- internal/agent/autonomy/runner_exec.go | 67 ++++----------- internal/agent/autonomy/runner_exec_test.go | 83 +++++++++---------- internal/agent/autonomy/schema.sql | 6 -- internal/agent/autonomy/store.go | 7 +- internal/agent/autonomy/store_test.go | 7 +- internal/doctrine/prompts.go | 2 +- 15 files changed, 220 insertions(+), 199 deletions(-) delete mode 100644 internal/agent/autonomy/environment.go delete mode 100644 internal/agent/autonomy/facts.go create mode 100644 internal/agent/autonomy/memory.go create mode 100644 internal/agent/autonomy/memory_test.go create mode 100644 internal/agent/autonomy/migrations/003_action_job_id.sql diff --git a/internal/agent/autonomy/action.go b/internal/agent/autonomy/action.go index c3e48e9..6234af6 100644 --- a/internal/agent/autonomy/action.go +++ b/internal/agent/autonomy/action.go @@ -81,6 +81,25 @@ func (s *Store) CompleteAction(ctx context.Context, id string, status ActionStat } 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 { diff --git a/internal/agent/autonomy/crud.go b/internal/agent/autonomy/crud.go index df09c79..578bd84 100644 --- a/internal/agent/autonomy/crud.go +++ b/internal/agent/autonomy/crud.go @@ -262,52 +262,13 @@ func (s *Store) SetResourceStatus(ctx context.Context, id, status string) error return nil } -// --- Facts --- - -func (s *Store) InsertFact(ctx context.Context, f Fact) error { - now := time.Now().UTC() - _, err := s.db.ExecContext(ctx, `INSERT INTO facts(id,objective_id,key,value_json,source,evidence_json,confidence,confirmed,scope,sensitivity,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`, - f.ID, f.ObjectiveID, f.Key, string(f.Value), f.Source, jsonOr(f.Evidence, "[]"), f.Confidence, boolInt(f.Confirmed), f.Scope, f.Sensitivity, stamp(now), stamp(now)) - return err -} - -func (s *Store) ListFacts(ctx context.Context, objectiveID string) ([]Fact, error) { - rows, err := s.db.QueryContext(ctx, `SELECT id,objective_id,key,value_json,source,evidence_json,confidence,confirmed,scope,sensitivity,created_at,updated_at FROM facts WHERE objective_id=? ORDER BY created_at`, objectiveID) - if err != nil { - return nil, err - } - defer rows.Close() - var out []Fact - for rows.Next() { - var f Fact - var value, evidence, created, updated string - var confirmed int - if err := rows.Scan(&f.ID, &f.ObjectiveID, &f.Key, &value, &f.Source, &evidence, &f.Confidence, &confirmed, &f.Scope, &f.Sensitivity, &created, &updated); err != nil { - return nil, err - } - f.Value, f.Evidence = json.RawMessage(value), json.RawMessage(evidence) - f.Confirmed = confirmed != 0 - f.CreatedAt, _ = time.Parse(time.RFC3339Nano, created) - f.UpdatedAt, _ = time.Parse(time.RFC3339Nano, updated) - out = append(out, f) - } - return out, rows.Err() -} - -func boolInt(b bool) int { - if b { - return 1 - } - return 0 -} - // --- 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,''),created_at,updated_at FROM actions WHERE objective_id=? ORDER BY created_at DESC LIMIT ?`, objectiveID, limit) + 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 } @@ -316,7 +277,7 @@ func (s *Store) ListActions(ctx context.Context, objectiveID string, limit int) 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, &created, &updated); err != nil { + 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) diff --git a/internal/agent/autonomy/environment.go b/internal/agent/autonomy/environment.go deleted file mode 100644 index db8a673..0000000 --- a/internal/agent/autonomy/environment.go +++ /dev/null @@ -1,15 +0,0 @@ -package autonomy - -import "encoding/json" - -type Observation struct { - ResourceID, Kind string - Value json.RawMessage - Evidence []string - Sensitive bool -} -type Environment struct { - Resources []ResourceGrantModel - Facts []StructuredFact - Observations []Observation -} diff --git a/internal/agent/autonomy/facts.go b/internal/agent/autonomy/facts.go deleted file mode 100644 index ada6206..0000000 --- a/internal/agent/autonomy/facts.go +++ /dev/null @@ -1,17 +0,0 @@ -package autonomy - -import "encoding/json" - -type StructuredFact struct { - Key string - Value json.RawMessage - Source string - Evidence []string - Confidence float64 - Confirmed bool - Sensitivity, Scope string -} - -func (f StructuredFact) UsableForExternalRepresentation(policyAllowsInferred bool) bool { - return f.Confirmed || policyAllowsInferred -} 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/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/model.go b/internal/agent/autonomy/model.go index 77d487a..7cbec74 100644 --- a/internal/agent/autonomy/model.go +++ b/internal/agent/autonomy/model.go @@ -49,18 +49,13 @@ type Resource struct { CreatedAt, UpdatedAt time.Time } -type Fact struct { - ID, ObjectiveID, Key, Source, Scope, Sensitivity string - Value, Evidence json.RawMessage - Confidence float64 - Confirmed bool - CreatedAt, UpdatedAt time.Time -} - type Action struct { ID, ObjectiveID, SubgoalID, RunID, Kind, Target, ConsequenceClass, PolicyHash, Status, IdempotencyKey string - Request, Result, Evidence json.RawMessage - CreatedAt, UpdatedAt time.Time + // 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 { diff --git a/internal/agent/autonomy/resources_test.go b/internal/agent/autonomy/resources_test.go index ff67e11..64e668e 100644 --- a/internal/agent/autonomy/resources_test.go +++ b/internal/agent/autonomy/resources_test.go @@ -31,17 +31,6 @@ func TestResourceGrantCanonicalBoundaryAndExpiration(t *testing.T) { t.Fatal("expired grant allowed") } } -func TestConfirmedFactsGateExternalRepresentation(t *testing.T) { - if (StructuredFact{}).UsableForExternalRepresentation(false) { - t.Fatal("unconfirmed fact allowed") - } - if !(StructuredFact{Confirmed: true}).UsableForExternalRepresentation(false) { - t.Fatal("confirmed fact denied") - } - if !(StructuredFact{}).UsableForExternalRepresentation(true) { - t.Fatal("policy-authorized inferred fact denied") - } -} // Regression: a symlink inside a granted dir pointing outside must NOT satisfy // the grant (Codex P0). PathWithinGrant resolves the requested path's symlinks. diff --git a/internal/agent/autonomy/runner_exec.go b/internal/agent/autonomy/runner_exec.go index 489b62f..8833394 100644 --- a/internal/agent/autonomy/runner_exec.go +++ b/internal/agent/autonomy/runner_exec.go @@ -72,15 +72,11 @@ var executiveToolDefs = []wire.ToolDef{ }, "id", "description", "status"), }, { - Name: "note_fact", - Description: "Record a structured fact about the environment with evidence. Facts gate later external representation: only confirmed facts may be presented externally.", + 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{ - "key": strProp("fact key, e.g. environment.deps.outdated_count"), - "value": map[string]any{"type": "string", "description": "JSON-encoded value"}, - "source": strProp("where this was observed"), - "confirmed": map[string]any{"type": "boolean", "description": "true only if directly verified"}, - "sensitivity": strProp("public|private|secret"), - }, "key", "value", "source"), + "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", @@ -342,10 +338,11 @@ func (e *Executive) stateSummary(p DelegationPolicy) string { fmt.Fprintf(&b, " - [%s] %s (%s)\n", g.Status, g.Description, g.ID) } } - if facts, err := e.Store.ListFacts(context.Background(), "primary"); err == nil && len(facts) > 0 { - b.WriteString("Known facts:\n") - for _, f := range facts { - fmt.Fprintf(&b, " - %s = %s (source %s)\n", f.Key, string(f.Value), f.Source) + 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() @@ -395,20 +392,17 @@ func (e *Executive) execTool(ctx context.Context, runID string, p DelegationPoli } return toolResult{content: "subgoal " + in.ID + " recorded"}, nil, nil - case "note_fact": + case "remember": var in struct { - Key, Value, Source, Sensitivity string - Confirmed bool + Note string `json:"note"` } if err := json.Unmarshal(call.Input, &in); err != nil { return toolResult{}, nil, err } - f := Fact{ID: fmt.Sprintf("fact-%d", now.UnixNano()), ObjectiveID: "primary", Key: in.Key, - Value: json.RawMessage(in.Value), Source: in.Source, Confirmed: in.Confirmed, Sensitivity: in.Sensitivity} - if err := e.Store.InsertFact(ctx, f); err != nil { + if err := AppendMemory(e.Home, in.Note); err != nil { return toolResult{}, nil, err } - return toolResult{content: "fact recorded: " + in.Key}, nil, nil + return toolResult{content: "remembered"}, nil, nil case "read_file": var in struct{ Path string } @@ -535,12 +529,10 @@ func (e *Executive) execTool(ctx context.Context, runID string, p DelegationPoli _ = e.Store.CompleteAction(ctx, actID, ActionFailed, json.RawMessage(fmt.Sprintf(`{%q:%q}`, "error", err.Error())), nil) return toolResult{}, nil, err } - // Record the job↔action mapping as a fact so check_delegate can find the - // action to complete later; RunOnce is one bounded wake, so the result - // necessarily arrives on a subsequent wake, not this one. - mapping, _ := json.Marshal(map[string]any{"action_id": actID, "task": in.Task, "status": "running"}) - _ = e.Store.InsertFact(ctx, Fact{ID: fmt.Sprintf("fact-%d", now.UnixNano()), ObjectiveID: "primary", - Key: "delegation." + job.ID, Value: mapping, Source: "delegate", Confirmed: true}) + // 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": @@ -561,7 +553,7 @@ func (e *Executive) execTool(ctx context.Context, runID string, p DelegationPoli 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.delegationActionID(ctx, in.JobID) + 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)) @@ -616,29 +608,6 @@ func (e *Executive) delegateRoot() (string, error) { return dir, nil } -// delegationActionID recovers the action id a delegate call recorded for jobID -// (as a fact, since facts are the only durable log delegate can write to -// without a schema migration), so check_delegate can close it out. -func (e *Executive) delegationActionID(ctx context.Context, jobID string) string { - facts, err := e.Store.ListFacts(ctx, "primary") - if err != nil { - return "" - } - key := "delegation." + jobID - for i := len(facts) - 1; i >= 0; i-- { - if facts[i].Key != key { - continue - } - var v struct { - ActionID string `json:"action_id"` - } - if json.Unmarshal(facts[i].Value, &v) == nil { - return v.ActionID - } - } - return "" -} - // 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 diff --git a/internal/agent/autonomy/runner_exec_test.go b/internal/agent/autonomy/runner_exec_test.go index 72ea0fd..d972be6 100644 --- a/internal/agent/autonomy/runner_exec_test.go +++ b/internal/agent/autonomy/runner_exec_test.go @@ -97,10 +97,10 @@ func TestExecutiveBlocksWithoutPolicy(t *testing.T) { 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", "note_fact", map[string]any{"key": "deps.outdated", "value": "3", "source": "scan", "confirmed": true})}}, + {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, _ := newTestExecutive(t, prov) + ex, st, home := newTestExecutive(t, prov) approveTestPolicy(t, st) out, err := ex.RunOnce(context.Background()) if err != nil { @@ -112,14 +112,14 @@ func TestExecutiveRunsAndJournals(t *testing.T) { if !strings.Contains(out.Report, "outdated") { t.Fatalf("report=%q", out.Report) } - // Subgoal + fact recorded. + // 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) } - facts, _ := st.ListFacts(context.Background(), "primary") - if len(facts) != 1 || facts[0].Key != "deps.outdated" { - t.Fatalf("facts=%v", facts) + 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) @@ -245,6 +245,34 @@ func TestPolicyApprovalMovesObjectiveActive(t *testing.T) { // 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{ @@ -272,16 +300,7 @@ func TestExecutiveDelegateFailsClosedWithoutBroker(t *testing.T) { t.Fatal(err) } // The delegate tool call must have failed (surfaced as a tool_result error, - // not a spawned job) — no delegation fact should exist. - facts, err := st.ListFacts(ctx, "primary") - if err != nil { - t.Fatal(err) - } - for _, f := range facts { - if strings.HasPrefix(f.Key, "delegation.") { - t.Fatalf("expected no delegation to have been spawned without a broker, got fact %v", f) - } - } + // not a spawned job) — no delegate action should have been journaled. actions, err := st.ListActions(ctx, "primary", 10) if err != nil { t.Fatal(err) @@ -343,19 +362,9 @@ func TestExecutiveDelegateUsesExistingChromeWhenBrokerRunning(t *testing.T) { if _, err := ex.RunOnce(ctx); err != nil { t.Fatal(err) } - facts, err := st.ListFacts(ctx, "primary") - if err != nil { - t.Fatal(err) - } - var jobID string - for _, f := range facts { - if strings.HasPrefix(f.Key, "delegation.") { - jobID = strings.TrimPrefix(f.Key, "delegation.") - } - } - if jobID == "" { - t.Fatalf("no delegation fact recorded: %v", facts) - } + // 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) @@ -403,19 +412,9 @@ func TestExecutiveDelegatesToWorker(t *testing.T) { t.Fatalf("status=%s report=%s", out.Status, out.Report) } - facts, err := st.ListFacts(ctx, "primary") - if err != nil { - t.Fatal(err) - } - var jobID string - for _, f := range facts { - if strings.HasPrefix(f.Key, "delegation.") { - jobID = strings.TrimPrefix(f.Key, "delegation.") - } - } - if jobID == "" { - t.Fatalf("no delegation fact recorded: %v", facts) - } + // 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 { diff --git a/internal/agent/autonomy/schema.sql b/internal/agent/autonomy/schema.sql index 23e89e6..f5306cd 100644 --- a/internal/agent/autonomy/schema.sql +++ b/internal/agent/autonomy/schema.sql @@ -29,12 +29,6 @@ CREATE TABLE IF NOT EXISTS resources ( 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 facts ( - id TEXT PRIMARY KEY, objective_id TEXT NOT NULL, key TEXT NOT NULL, value_json TEXT NOT NULL, - source TEXT NOT NULL, evidence_json TEXT NOT NULL DEFAULT '[]', confidence REAL NOT NULL DEFAULT 0, - confirmed INTEGER NOT NULL DEFAULT 0, scope TEXT NOT NULL DEFAULT '', sensitivity TEXT NOT NULL DEFAULT '', - 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, diff --git a/internal/agent/autonomy/store.go b/internal/agent/autonomy/store.go index efa4e60..e5f6791 100644 --- a/internal/agent/autonomy/store.go +++ b/internal/agent/autonomy/store.go @@ -18,9 +18,12 @@ 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} +var migrations = []string{schema, migration002, migration003} type Store struct{ db *sql.DB } @@ -154,7 +157,7 @@ func (s *Store) SetObjectiveText(ctx context.Context, id, description string) er 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", "facts", "actions", "generated_items", "notifications"} { + 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 diff --git a/internal/agent/autonomy/store_test.go b/internal/agent/autonomy/store_test.go index 2accc7b..f9cbe4f 100644 --- a/internal/agent/autonomy/store_test.go +++ b/internal/agent/autonomy/store_test.go @@ -24,7 +24,7 @@ func TestOpenInitializesHomeAndSchema(t *testing.T) { t.Errorf("missing %s: %v", path, err) } } - for _, table := range []string{"objectives", "subgoals", "runs", "triggers", "policies", "resources", "facts", "actions", "generated_items", "notifications"} { + 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) @@ -65,9 +65,6 @@ func TestObjectiveAndDomainNeutralRecordsPersist(t *testing.T) { 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.InsertFact(ctx, Fact{ID: "f1", ObjectiveID: "o1", Key: "environment.state", Value: json.RawMessage(`{}`), Source: "observation"}); err != nil { - t.Fatal(err) - } if err := s.InsertNotification(ctx, Notification{ID: "n1", ObjectiveID: "o1", Kind: "info"}); err != nil { t.Fatal(err) } @@ -77,7 +74,7 @@ func TestObjectiveAndDomainNeutralRecordsPersist(t *testing.T) { t.Fatal(err) } defer s.Close() - for _, table := range []string{"subgoals", "runs", "triggers", "policies", "resources", "facts", "notifications"} { + 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) diff --git a/internal/doctrine/prompts.go b/internal/doctrine/prompts.go index 11f9f09..65f3646 100644 --- a/internal/doctrine/prompts.go +++ b/internal/doctrine/prompts.go @@ -409,7 +409,7 @@ Rules you must follow: - 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 note_fact. Break the objective into subgoals with subgoal_update. +- 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 From d59b9c6cd5eb0b7ca1111d359f3d08181454316e Mon Sep 17 00:00:00 2001 From: Tim Erwin Date: Sun, 30 Aug 2026 16:18:36 +0700 Subject: [PATCH 12/13] config/docs: reject the removed kind: field loudly; document autonomy as a mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidation step 5, finishing the merge. MIGRATION. YAML silently ignores unknown fields, so an existing `kind: personal` agent would have loaded as an ordinary one — still listed, apparently fine, and never waking again. For a change that removes an agent's authority to run on its own, silence is the wrong failure. gwconfig.Agent keeps a LegacyKind field for the sole purpose of rejecting it, with the one-line fix in the error: agent "demo" still uses the removed `kind: personal` setting. Autonomy is now explicit: replace it with `autonomous: true` (and an `objective:` ...), or just delete the `kind:` line if it should not. Its home ... is untouched Verified against the real local config, which has exactly that agent. The guard's TestNoAgentKind is refined to allow this one mention while still banning any behavioral branch on it. DOCS. docs/personal-agents.md -> docs/autonomous-agents.md, rewritten around the two orthogonal grants and the four combinations they produce, with the scheduling split (your cadence = an ordinary schedule; the agent's own next wake = self-scheduled), the fail-closed existing-Chrome rule, and the memory.md provenance limitation stated rather than glossed. docs/design/personal-agents.md -> docs/design/autonomous-agents.md keeps the original design contract but opens with a note on what the implementation revised and why, including the orthogonality correction. It is a design record; rewriting history there would lose the reasoning. README drops "three ways to run it" (there are two) and describes autonomy as a setting rather than a product tier, leading with the case that actually motivated it: a scheduled agent that is finally policy-gated, journaled, and able to stop and ask. --- README.md | 21 ++- docs/autonomous-agents.md | 156 ++++++++++++++++++ ...ersonal-agents.md => autonomous-agents.md} | 30 +++- docs/gateway/README.md | 24 ++- docs/personal-agents.md | 55 ------ internal/gateway/config/config.go | 10 ++ internal/gateway/config/config_test.go | 17 ++ internal/guard/singletons_test.go | 14 +- 8 files changed, 252 insertions(+), 75 deletions(-) create mode 100644 docs/autonomous-agents.md rename docs/design/{personal-agents.md => autonomous-agents.md} (82%) delete mode 100644 docs/personal-agents.md diff --git a/README.md b/README.md index a3f0384..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, three 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. **Personal** is the cockpit for domain-general, long-lived environment agents; Gateway remains its durable scheduling and execution engine. All modes 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,11 +56,24 @@ 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. -## Personal Agents +## Agents that run on their own -`memcode personal create ` creates a named Personal Agent with an arbitrary long-lived objective. The executive breaks it into subgoals, runs bounded wakes (via `personal run` or gateway triggers), records facts, and pauses durably for human input with exact resume. +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`. -Authority is versioned and approved by hash: no consequential work runs without an approved policy. Resource grants confine file access, consequential actions are journaled, and generated code runs fail-closed when no sandbox is available. State lives under `~/.memcode/agents//`; removing config is non-destructive. See `docs/personal-agents.md`. +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 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/personal-agents.md b/docs/design/autonomous-agents.md similarity index 82% rename from docs/design/personal-agents.md rename to docs/design/autonomous-agents.md index 2bee3f6..1dfd7e1 100644 --- a/docs/design/personal-agents.md +++ b/docs/design/autonomous-agents.md @@ -1,23 +1,39 @@ -# Personal Agents +# Autonomous agents -**Status:** Draft design contract +**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 -Personal Agents are domain-general, long-lived environment agents operated through: +Autonomous agents are domain-general, long-lived environment agents configured +on any agent and operated through: ```text -memcode personal +memcode admin ``` -A Personal 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. +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/personal` contains no domain-specific workflow concepts, fixed worker roles, provider-specific business logic, or predefined user-profile schema. +> `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. @@ -130,7 +146,7 @@ After meaningful work the executive evaluates progress, cost, latency, repeated ## Browser broker trust boundary -Ordinary sessions retain the existing ephemeral browser backend. Personal Agents may use an explicitly authorized connection to the user's existing Chrome through a gateway-owned broker and permission-protected local socket. +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. diff --git a/docs/gateway/README.md b/docs/gateway/README.md index decb07c..a46fbc9 100644 --- a/docs/gateway/README.md +++ b/docs/gateway/README.md @@ -95,8 +95,10 @@ projects: # written by `memcode project add` enabled: true default_project: memcode agents: # durable agents; identity + state in ~/.memcode/agents/ - personal: - kind: personal # additive Personal Agent runtime; omit for ordinary agents + 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 @@ -135,11 +137,19 @@ 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. -An optional `kind: personal` marks an additive Personal Agent runtime type. -Empty `kind` preserves ordinary named-agent behavior. Personal objective, -policy, resource, trigger, and runtime state lives in the agent home rather than -`gateway.yaml`; manage that lifecycle through `memcode personal`. Removing the -configuration entry does not delete the home. +`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 diff --git a/docs/personal-agents.md b/docs/personal-agents.md deleted file mode 100644 index 9c58379..0000000 --- a/docs/personal-agents.md +++ /dev/null @@ -1,55 +0,0 @@ -# Personal Agents - -Personal Agents are domain-general, persistent environment agents operated through `memcode personal`. Personal is the cockpit; the existing Gateway daemon is the engine room for durable trigger intake and scheduled wakes. - -## Quick start - -``` -memcode personal # interactive cockpit (like memcode admin) -memcode personal create "" -memcode personal policy set policy.json # stage a draft -memcode personal approve-policy # approve by hash -memcode personal run # one bounded wake -memcode personal triggers add interval 30m # recurring wakes (gateway) -memcode personal doctor -``` - -Bare `memcode personal` opens an interactive management session — the same TUI as `memcode admin` — where you manage agents in plain language through typed, gated `pa_*` operations (objective, policy, resources, triggers, wake, inbox, answer, history, lifecycle). The subcommands are the same operations in scriptable form. - -A minimal policy (`policy.json`): - -```json -{ - "objective_scope": "primary", - "consequence_classes": ["observe", "local_mutation"], - "max_seconds": 300, - "max_actions_per_period": 8, - "max_delegation_depth": 1 -} -``` - -## How it works - -- **Objective** — the approved desired outcome and success criteria. The executive breaks it into subgoals (data, not compiled workflow types). -- **Bounded wakes** — each `run`/trigger wake is a single bounded LLM loop. It ends by calling `report`, scheduling the next wake with `schedule_wake`, or suspending with `ask_user`. The agent never runs continuously. -- **Policy gate** — consequential work requires an approved policy. `RunOnce` fails closed before any model call if no policy is approved, or if the policy is expired/revoked. Approving a policy activates the objective. -- **Resource grants** — `resources add` grants filesystem roots (canonical, symlink-resolved) with an access mode. `read_file`/`write_file` in the executive are confined to grants; the agent's own home and generated workspace are always available. `resources revoke` takes effect at the next dispatch. -- **Journal** — consequential executive actions (e.g. `write_file`) are journaled with reserve → running → succeeded/failed and the policy hash, before dispatch. -- **Triggers** — durable `interval` / `cron` / `one-shot` / `next_wake` records in the agent home. The running gateway polls them every 15s, claims each due trigger atomically, and runs a wake. -- **Human-in-the-loop** — `ask_user` suspends a run durably: the interaction is recorded in the agent's DB and the exact continuation (transcript + tool_use_id) is saved under `runs//`. `personal inbox` lists pending questions; `personal answer ` resolves it and resumes with the matching tool_result — no replay of completed actions, no double-resume. - -## Commands - -Interactive cockpit: bare `memcode personal` (typed `pa_*` tools). Scriptable subcommands: `create` `list` `show` `run` `inbox` `answer` `pause` `resume` `stop` `delete` · `policy set|show` + `approve-policy` · `resources add|list|revoke` · `triggers add|list|pause|resume` · `history` · `doctor` - -## Controls and safety - -`pause`/`stop` change objective status so future wakes refuse to run. `delete` removes the config entry but keeps the agent home; `--delete-home` is the explicit destructive path. State lives under `~/.memcode/agents//` (`personal.db` with WAL + versioned migrations, `policies/`, `runs/`, `workspace/`). - -Generated code is untrusted: `RunGenerated` uses staged inputs, a scrubbed environment, executable allowlists, and bounded time/output, and fails closed when a hardened sandbox (Linux `bwrap`) is unavailable. `doctor` reports sandbox availability as informational. - -## Current scope - -Implemented and tested: objective/subgoal/fact store, policy gate, journaled bounded executive with observe + local-mutation tools, durable triggers via the gateway, suspend/resume, resources, history, and doctor. - -Not yet wired into the executive loop (the primitives exist and are unit-tested): dynamically delegated sub-agents, the existing-Chrome broker, external-consequence classes (external_effect/financial/legal/destructive), and adaptive pacing as a live input to dispatch. Native desktop automation remains a future backend. diff --git a/internal/gateway/config/config.go b/internal/gateway/config/config.go index 2ef6235..652bb53 100644 --- a/internal/gateway/config/config.go +++ b/internal/gateway/config/config.go @@ -96,6 +96,13 @@ 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. @@ -432,6 +439,9 @@ func Load() (Settings, error) { // 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) } diff --git a/internal/gateway/config/config_test.go b/internal/gateway/config/config_test.go index 3de8b50..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" ) @@ -155,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/guard/singletons_test.go b/internal/guard/singletons_test.go index 8455747..a4f2739 100644 --- a/internal/guard/singletons_test.go +++ b/internal/guard/singletons_test.go @@ -121,10 +121,20 @@ func TestNoSecondCockpit(t *testing.T) { // 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) { - if strings.Contains(src, `Kind: "personal"`) || strings.Contains(src, `kind == "personal"`) || strings.Contains(src, `Kind == "personal"`) { - t.Errorf("%s still discriminates on an agent kind — use Agent.Autonomous / Agent.Objective", path) + 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)) + } } } } From 0d3340e34918ddaf20d1dddb5974e5bd4ddc3315 Mon Sep 17 00:00:00 2001 From: Tim Erwin Date: Sun, 30 Aug 2026 16:19:48 +0700 Subject: [PATCH 13/13] autonomy: personal.db -> agent.db; cover the last two matrix rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The store file was still named personal.db. It holds state for any agent running unattended, not a species of agent — rename it, and note in place that it is opened lazily so an ordinary conversational agent never grows one. That claim is now a test rather than a comment: TestOrdinaryAgentGetsNoAutonomyStore creates a plain agent, exercises the admin surface against it, and asserts no agent.db appears — the governance machinery costs nothing until asked for. Also covers the remaining orthogonality row: an agent WITH an objective but WITHOUT autonomy still wakes on demand, reaching the policy gate rather than being refused for lacking autonomy. Autonomy governs unprompted action, not whether a human may ask. All four combinations of (objective, autonomous) now have a test. --- cmd/admin_autonomy_test.go | 34 +++++++++++++++++++++++++++ internal/agent/autonomy/store.go | 5 +++- internal/agent/autonomy/store_test.go | 2 +- 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/cmd/admin_autonomy_test.go b/cmd/admin_autonomy_test.go index 4e0f9fb..aa97830 100644 --- a/cmd/admin_autonomy_test.go +++ b/cmd/admin_autonomy_test.go @@ -244,3 +244,37 @@ func TestDoctorAndInbox(t *testing.T) { 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/internal/agent/autonomy/store.go b/internal/agent/autonomy/store.go index e5f6791..e638726 100644 --- a/internal/agent/autonomy/store.go +++ b/internal/agent/autonomy/store.go @@ -44,7 +44,10 @@ func Open(ctx context.Context, home string) (*Store, error) { if err := InitializeHome(home); err != nil { return nil, fmt.Errorf("initialize agent home: %w", err) } - path := filepath.Join(home, "personal.db") + // 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) diff --git a/internal/agent/autonomy/store_test.go b/internal/agent/autonomy/store_test.go index f9cbe4f..eca1435 100644 --- a/internal/agent/autonomy/store_test.go +++ b/internal/agent/autonomy/store_test.go @@ -19,7 +19,7 @@ func TestOpenInitializesHomeAndSchema(t *testing.T) { } defer s.Close() - for _, path := range []string{"personal.db", "policies", "workspace/generated", "workspace/scratch", "runs", "workers", ".memcode/jobs", ".memcode/sessions"} { + 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) }