diff --git a/control-plane/internal/handlers/ui/agent_secrets.go b/control-plane/internal/handlers/ui/agent_secrets.go index 6c465c141..608573fe3 100644 --- a/control-plane/internal/handlers/ui/agent_secrets.go +++ b/control-plane/internal/handlers/ui/agent_secrets.go @@ -3,6 +3,7 @@ package ui import ( "encoding/json" "net/http" + "os" "regexp" "sort" @@ -23,18 +24,20 @@ var agentSecretKeyPattern = regexp.MustCompile(`^[A-Z][A-Z0-9_]*$`) type AgentSecretsHandler struct { storage storage.StorageProvider agentfieldHome string + lookupEnv func(string) (string, bool) } // NewAgentSecretsHandler creates an AgentSecretsHandler. func NewAgentSecretsHandler(storage storage.StorageProvider, agentfieldHome string) *AgentSecretsHandler { - return &AgentSecretsHandler{storage: storage, agentfieldHome: agentfieldHome} + return &AgentSecretsHandler{storage: storage, agentfieldHome: agentfieldHome, lookupEnv: os.LookupEnv} } type agentSecretStatus struct { Key string `json:"key"` IsSet bool `json:"is_set"` + Env bool `json:"env,omitempty"` // Scope reports where the stored value lives ("node" or "global"); - // empty when the key is not set anywhere. + // empty when there is no stored value, including environment-only keys. Scope string `json:"scope,omitempty"` DeclaredScope string `json:"declared_scope,omitempty"` Description string `json:"description,omitempty"` @@ -66,9 +69,10 @@ type setAgentSecretRequest struct { } // ListAgentSecretsHandler lists secret names and whether each resolves for -// this agent. Resolution mirrors the runner (EnvResolver): node scope first, -// then global. Undeclared node-scoped keys are included because the runner -// injects them; undeclared global keys are not injected, so they are omitted. +// this agent. Resolution mirrors the runner (EnvResolver): a non-empty process +// environment value first, then node store, then global store. Values are never +// returned. Undeclared node-scoped keys are included because the runner injects +// them; undeclared global keys are not injected, so they are omitted. func (h *AgentSecretsHandler) ListAgentSecretsHandler(c *gin.Context) { agentPackage, ok := h.resolveAgentPackage(c) if !ok { @@ -123,9 +127,17 @@ func (h *AgentSecretsHandler) ListAgentSecretsHandler(c *gin.Context) { } sort.Strings(keys) + lookupEnv := h.lookupEnv + if lookupEnv == nil { + lookupEnv = os.LookupEnv + } secrets := make([]agentSecretStatus, 0, len(keys)) for _, key := range keys { status := agentSecretStatus{Key: key} + if value, ok := lookupEnv(key); ok && value != "" { + status.Env = true + status.IsSet = true + } switch { case inNode[key]: status.IsSet = true diff --git a/control-plane/internal/handlers/ui/agent_secrets_test.go b/control-plane/internal/handlers/ui/agent_secrets_test.go index eb83f493d..6e094cb06 100644 --- a/control-plane/internal/handlers/ui/agent_secrets_test.go +++ b/control-plane/internal/handlers/ui/agent_secrets_test.go @@ -20,6 +20,13 @@ import ( const agentSecretsTestScope = "test-node" func newAgentSecretsTestRouter(t *testing.T) (*gin.Engine, string) { + return newAgentSecretsTestRouterWithLookup(t, func(string) (string, bool) { return "", false }) +} + +func newAgentSecretsTestRouterWithLookup( + t *testing.T, + lookupEnv func(string) (string, bool), +) (*gin.Engine, string) { t.Helper() gin.SetMode(gin.TestMode) agentfieldHome := t.TempDir() @@ -44,6 +51,7 @@ func newAgentSecretsTestRouter(t *testing.T) (*gin.Engine, string) { require.NoError(t, err) handler := NewAgentSecretsHandler(store, agentfieldHome) + handler.lookupEnv = lookupEnv router := gin.New() router.GET("/agents/:agentId/secrets", handler.ListAgentSecretsHandler) router.PUT("/agents/:agentId/secrets", handler.SetAgentSecretHandler) @@ -52,6 +60,47 @@ func newAgentSecretsTestRouter(t *testing.T) (*gin.Engine, string) { return router, agentfieldHome } +func TestAgentSecretsListProcessEnvironmentResolution(t *testing.T) { + environment := map[string]string{ + "OPENAI_API_KEY": "env-value", + "ANTHROPIC_API_KEY": "", + "NODE_SCOPED_KEY": "env-value", + } + router, home := newAgentSecretsTestRouterWithLookup(t, func(key string) (string, bool) { + value, ok := environment[key] + return value, ok + }) + store, err := packages.NewSecretStore(home) + require.NoError(t, err) + require.NoError(t, store.Set(agentSecretsTestScope, "NODE_SCOPED_KEY", "stored-value")) + + response := agentSecretsRequest(t, router, http.MethodGet, "/agents/agent-x/secrets?include=env", "") + require.Equal(t, http.StatusOK, response.Code) + require.NotContains(t, response.Body.String(), "env-value") + require.NotContains(t, response.Body.String(), "stored-value") + require.JSONEq(t, `{"secrets":[ + {"key":"AGENTFIELD_SERVER","is_set":false,"declared_scope":"global","description":"Control-plane URL","default":"http://localhost:8080","requirement":"optional"}, + {"key":"ANTHROPIC_API_KEY","is_set":false,"declared_scope":"global","description":"Anthropic key","secret":true,"requirement":"one_of","group":"llm_provider","group_description":"an LLM provider key"}, + {"key":"NODE_SCOPED_KEY","is_set":true,"env":true,"scope":"node","declared_scope":"node","secret":true,"requirement":"required"}, + {"key":"OPENAI_API_KEY","is_set":true,"env":true,"declared_scope":"global","description":"OpenAI key","secret":true,"requirement":"required"}, + {"key":"SWE_DEFAULT_RUNTIME","is_set":false,"declared_scope":"global","description":"Coding runtime","requirement":"optional"} + ]}`, response.Body.String()) +} + +// A handler built without an injected lookup (a zero-value struct rather than +// the constructor) must still consult the real process environment. +func TestAgentSecretsListDefaultsToProcessEnvironment(t *testing.T) { + router, _ := newAgentSecretsTestRouterWithLookup(t, nil) + t.Setenv("OPENAI_API_KEY", "from-process") + t.Setenv("ANTHROPIC_API_KEY", "") + + response := agentSecretsRequest(t, router, http.MethodGet, "/agents/agent-x/secrets?include=env", "") + require.Equal(t, http.StatusOK, response.Code) + require.NotContains(t, response.Body.String(), "from-process") + require.Contains(t, response.Body.String(), `{"key":"OPENAI_API_KEY","is_set":true,"env":true,`) + require.Contains(t, response.Body.String(), `{"key":"ANTHROPIC_API_KEY","is_set":false,`) +} + func agentSecretsRequest(t *testing.T, router http.Handler, method, path, body string) *httptest.ResponseRecorder { t.Helper() request := httptest.NewRequest(method, path, strings.NewReader(body)) @@ -63,6 +112,10 @@ func agentSecretsRequest(t *testing.T, router http.Handler, method, path, body s // Validation contract 1: PUT writes the node scope consumed by runner-side resolution. func TestAgentSecretsPutResolvesForRunner(t *testing.T) { + // EnvResolver prefers a non-empty process env value, so a developer + // machine with this key exported would resolve the host value instead of + // the stored one. Empty counts as unset; this keeps the test hermetic. + t.Setenv("OPENAI_API_KEY", "") router, home := newAgentSecretsTestRouter(t) response := agentSecretsRequest(t, router, http.MethodPut, "/agents/agent-x/secrets", `{"key":"OPENAI_API_KEY","value":"sk-test"}`) diff --git a/control-plane/internal/skillkit/catalog.go b/control-plane/internal/skillkit/catalog.go index 2e2d3d8fa..233e81df2 100644 --- a/control-plane/internal/skillkit/catalog.go +++ b/control-plane/internal/skillkit/catalog.go @@ -52,16 +52,17 @@ read this skill first`, }, { Name: "agentfield-use", - Version: "0.6.0", - Description: "Discover and call agents already running on a local or cloud AgentField control plane. Resolves the target server first (a desktop-configured cloud beats the local default, and an unreachable one is a stop-and-report, never a silent fallback), zero-setup MCP endpoint at /mcp, health check, capability discovery, ranked reasoner search (af agent search), the reasoner's exact contract fetched before the first dispatch, entry-point-only targeting, concurrent sync/async execution, load-aware pacing (meta.load), in-flight visibility (af ps / executions/active), wedged-run triage (cancel-tree), sessions, and the af CLI ops (run/stop/logs/secrets) that keep installed agents answering.", + Version: "0.8.0", + Description: "Offload work to installed AgentField agents — your subharnesses — instead of doing it inline: whenever a task arises, check whether an installed agent covers it and dispatch to it by default when one does. Resolves the target server first (local or cloud: a desktop-configured cloud beats the local default, and an unreachable one is a stop-and-report, never a silent fallback), zero-setup MCP endpoint at /mcp, health check, capability discovery, ranked reasoner search (af agent search), the reasoner's exact contract fetched before the first dispatch, entry-point-only targeting, async dispatch (af call --async) with wait/tail/batch-poll retrieval, a live run link at /ui/runs/, load-aware pacing (meta.load), in-flight visibility (af ps / executions/active), wedged-run triage (cancel-tree), honest cost reporting from the usage/stats window aggregate, sessions, and the af CLI ops (run/stop/logs/secrets) that keep installed agents answering.", EmbedRoot: "skill_data/agentfield-use", EntryFile: "SKILL.md", - Trigger: `When the user asks you to use, call, query, or delegate work to an -installed AgentField agent, to list available agents or reasoners, or to -check on a running execution, you MUST read this skill first — it resolves -which control plane the work goes to (local or the desktop-configured -cloud) and requires fetching a reasoner's contract, and confirming it is an -entry point, before dispatching to it`, + Trigger: `Before you do a task yourself, and whenever the user asks you to +use, call, query, or delegate work to an installed AgentField agent, to +list available agents or reasoners, or to check on a running execution, you +MUST read this skill first — installed agents are subharnesses you offload +to by default; it resolves which control plane the work goes to (local or +the desktop-configured cloud) and requires fetching a reasoner's contract, +and confirming it is an entry point, before dispatching to it`, }, } diff --git a/control-plane/internal/skillkit/catalog_agentfield_use_test.go b/control-plane/internal/skillkit/catalog_agentfield_use_test.go index 11ada7620..39dd32b4f 100644 --- a/control-plane/internal/skillkit/catalog_agentfield_use_test.go +++ b/control-plane/internal/skillkit/catalog_agentfield_use_test.go @@ -89,8 +89,8 @@ func TestAgentfieldUseSourceFallbackContract(t *testing.T) { if err != nil { t.Fatalf("parse source frontmatter: %v", err) } - if frontmatter.Name != "agentfield-use" || frontmatter.Version != "0.6.0" { - t.Fatalf("source frontmatter = %+v, want name=agentfield-use version=0.6.0", frontmatter) + if frontmatter.Name != "agentfield-use" || frontmatter.Version != "0.8.0" { + t.Fatalf("source frontmatter = %+v, want name=agentfield-use version=0.8.0", frontmatter) } // The offer is available only after coverage is conclusively checked, it @@ -147,6 +147,102 @@ func TestAgentfieldUseDispatchPreconditions(t *testing.T) { } } +// Contract for 0.7.0: an installed-but-unstarted node is the DEFAULT first-run +// state (the desktop ships swe-planner/pr-af provisioned but not started), so +// the skill must start the node before dispatching and must treat the resulting +// missing-key error as a blocking handoff. Without this the agent only ever +// sees "agent 'X' not found" and silently substitutes something else. +func TestAgentfieldUseMissingKeyHandoffContract(t *testing.T) { + content := string(skillSource(t, "agentfield-use")) + for _, needle := range []string{ + // Start before dispatch, and why the start attempt is the diagnostic. + "### Start it before you dispatch — the start attempt is the diagnostic", + "run `af run ` BEFORE dispatching", + "missing required environment variables: OPENROUTER_API_KEY", + // The store-blind commands must stay called out by name. + "Do not use `af doctor` or `af config --list` to decide", + // Blocking handoff, never a workaround. + "A missing key is a blocking handoff, not a problem to route around.", + "AgentField Desktop → Agents → →", + "do NOT substitute a", + "Never ask the user to paste the secret value into the conversation", + // The observed not-found responses must be recognizable. + "agent 'X' not found", + "target \"X.y\" not found", + } { + if !strings.Contains(content, needle) { + t.Fatalf("agentfield-use SKILL.md is missing missing-key handoff text %q", needle) + } + } +} + +// Contract for 0.8.0: installed agents are subharnesses a coding harness +// offloads to, and offloading is the DEFAULT path rather than an option to +// offer. Each clause below exists because dropping it turns the offload back +// into inline work the user never hears about — the failure this release was +// written to prevent. +func TestAgentfieldUseOffloadDoctrineContract(t *testing.T) { + content := string(skillSource(t, "agentfield-use")) + for _, needle := range []string{ + // Offload by default — coverage decides, not the task's size, and the + // fleet is discovered at runtime rather than listed here. + "## Offload by default", + "default path, not an option to offer", + "**Coverage is the test, not size.**", + "**The check is cheap — that is the whole design.**", + "**Default-offload.**", + // Announce the offload, with the run's live UI link for the user. + "**Announce it, with a link.**", + "/ui/runs/", + "The link is **for the user** to watch in parallel.", + // The user keeps the override. + "**The user can always override.**", + // Never silent-wash: a failed or stalled run is reported, never redone + // inline and presented as the subharness's work. + "**Never silent-wash the offload.**", + "Do NOT quietly redo the work inline", + // The user-facing vocabulary rule. + "**Vocabulary rule.**", + "your AgentField subharnesses", + "subharnesses", + } { + if !strings.Contains(content, needle) { + t.Fatalf("agentfield-use SKILL.md is missing offload-doctrine text %q", needle) + } + } + // The doctrine must not re-introduce a size gate or a hardcoded list of + // offloadable roles: coverage is the only test, and the fleet is open-ended. + for _, forbidden := range []string{"substantial, multi-step work", "A security audit → "} { + if strings.Contains(content, forbidden) { + t.Fatalf("agentfield-use SKILL.md re-introduced retired offload gate %q", forbidden) + } + } +} + +// Contract for 0.8.0: the async golden path the harness actually drives — +// client-side-validated dispatch, then a retrieval mode chosen deliberately. +// `af wait`'s exit 2 is a timeout, not a failure; a harness must never wait on +// a webhook it has no listener for; and cost is a window aggregate, never a +// per-run figure to invent. +func TestAgentfieldUseAsyncGoldenPathContract(t *testing.T) { + content := string(skillSource(t, "agentfield-use")) + for _, needle := range []string{ + "af call . --schema", + "--async", + "af wait ", + "af tail ", + "**Exit code 2 means TIMEOUT, not failure**", + "**Webhooks are not for you.**", + "/api/ui/v1/usage/stats", + "There is **no per-run cost endpoint today.**", + "**Duration is per-run truth; cost is window truth.**", + } { + if !strings.Contains(content, needle) { + t.Fatalf("agentfield-use SKILL.md is missing golden-path text %q", needle) + } + } +} + // Contract: the catalog entry is what a rules file and `af skill catalog` show // — it must advertise the same preconditions the skill body enforces, or an // agent choosing skills by description never learns they exist. diff --git a/control-plane/internal/skillkit/skill_data/agentfield-use/SKILL.md b/control-plane/internal/skillkit/skill_data/agentfield-use/SKILL.md index bf358dffc..b28eec25c 100644 --- a/control-plane/internal/skillkit/skill_data/agentfield-use/SKILL.md +++ b/control-plane/internal/skillkit/skill_data/agentfield-use/SKILL.md @@ -1,53 +1,107 @@ --- name: agentfield-use -version: 0.6.0 -description: "Discover and call agents already running on a local or cloud AgentField control plane. Use when the user asks to use, call, query, run, or delegate work to an installed AgentField agent (swe-planner, pr-af, sec-af, …), to list what agents or reasoners are available, or to check on an execution. Resolves the right control plane (desktop-configured cloud first), fetches the reasoner's exact contract before dispatching, and only calls entry-point reasoners. Not for building new agents — that is the agentfield skill." +version: 0.8.0 +description: "Whenever you have a discrete task to perform — one the user delegated, or one that arose inside your own work — check FIRST whether an installed AgentField agent covers it, and offload to it by default when one does. Coverage, not task size, is the test: even a small job goes to a covering agent. The check is cheap — one `af ls -e` or capability lookup answers it, and the roster can be cached for the session. These agents are your AgentField subharnesses: workers running on cheaper open models, in parallel, off your own context, every run recorded on the control plane and watchable at /ui/runs/. This skill cannot know what your fleet does — runtime discovery is the only source of truth (swe-planner, pr-af, and whatever else the user installed). Also the reactive path when the user asks to use, call, or run an agent, to list what agents or reasoners exist, or to check on an execution. Resolves the right control plane (local or desktop-configured cloud) first, fetches the reasoner's exact contract before dispatching, and calls entry-point reasoners only. Not for building new agents — that is the agentfield skill." --- -# Using AgentField agents +# AgentField subharnesses — offload work instead of doing it inline A machine with AgentField has one or more **control planes** — a local one -(default `http://localhost:8080`) and possibly a **cloud deployment** -configured in AgentField Desktop — plus **agent nodes** installed under -`~/.agentfield`. Each node exposes **reasoners** — typed functions you call -over HTTP. You never talk to an agent's own port: every call goes through the -control plane, which routes it, records the workflow, and returns the result. - -**Resolve which control plane you are targeting before anything else (§0).** -The local and cloud fleets are disjoint: different agents, different versions, -different filesystems, different run history. Nothing ever falls back from one -to the other on its own. - -A local server in local mode has no auth. A cloud deployment (and any server -with an API key configured) requires `X-API-Key: ` on every request. +(default `http://localhost:8080`) and possibly a **cloud deployment** configured +in AgentField Desktop — plus **agent nodes** installed under `~/.agentfield`. +Each node exposes **reasoners**: typed functions you call through the control +plane, which routes the call, records the run, and returns the result. You never +talk to an agent's own port. + +Those installed agents are **subharnesses**: workers you offload to. They run on +smaller, cheaper models, they run in parallel, they run **off your context**, and +every run is recorded on the control plane and watchable in its web UI. A review +that would cost you 40k tokens of reading costs you one dispatch and one result. + +**Vocabulary rule.** Internally — commands, API fields, discovery output — +everything stays `agent` / `reasoner` / `node`, exactly matching the CLI and API. +In what you SAY TO THE USER, call them subharnesses: "your AgentField +subharnesses", "the pr-af subharness". Never let the wording cost clarity: if the +user needs a command or a node name, give the literal one. + +## Offload by default + +Whenever you have a discrete task — one the user delegated, or one that arose +inside your own work — the first question is not "how do I do this?" but **"does +an installed subharness cover this?"** When one does, dispatching to it is the +default path, not an option to offer. + +**Coverage is the test, not size.** A small job a subharness covers still goes to +the subharness; a large job nothing covers stays with you. This skill carries no +list of offloadable task types and cannot: users install new subharnesses at any +time, so runtime discovery (§1) is the only source of truth about what your fleet +does. + +**The check is cheap — that is the whole design.** One `af ls -e` (or one +capabilities call) answers "does anything cover this?". Discover once per +session, remember the roster, and match later tasks against what you learned; +re-discover only when something suggests the fleet changed — an install, a start, +or a miss you doubt. Consulting this skill on a task nothing covers costs one +cheap lookup. That is the trade, deliberately. + +The contract, in four parts: + +1. **Default-offload.** Do not ask "would you like me to use an agent?" — check + coverage (§1), then dispatch. Work stays inline only when nothing covers it + (§1, "No coverage") or the user said to do it yourself. +2. **Announce it, with a link.** Tell the user what you offloaded, to which + subharness, and the run's live URL — built from the resolved server (§0) plus + the run_id, URL-encoded: `/ui/runs/`. + + > Offloading the PR #42 review to your pr-af subharness — follow along at + > http://localhost:8080/ui/runs/run_abc123 + + The link is **for the user** to watch in parallel. It never replaces your own + monitoring (§4) or your final report (§5). On a cloud target the link only + opens if the user's browser can reach that host — print it anyway, the URL + shape is identical. +3. **The user can always override.** "Do it yourself" ends the offload; do the + work inline and say so. Overrides are per-request, not permanent. +4. **Never silent-wash the offload.** If the offloaded run fails, stalls, or + comes back empty, **report that and ask.** Do NOT quietly redo the work inline + and present the output as if the subharness produced it. The same rule covers + a node that cannot start (§1): never substitute your own work for an agent's + without saying so — the user believes their agent ran. ## 0. Resolve the server first (local vs cloud) +The local and cloud fleets are disjoint: different agents, different versions, +different filesystems, different run history. Nothing ever falls back from one to +the other on its own. A local server in local mode has no auth; a cloud +deployment (and any server with an API key configured) requires +`X-API-Key: ` on every request. + Resolution order — stop at the first match: 1. **Explicit wins.** The user named a server, or `AGENTFIELD_SERVER` is set in the environment → use that. 2. **Read the desktop cloud config.** Check every path that applies to this - machine — a file that exists but declares no enabled cloud does NOT end - the search: + machine — a file that exists but declares no enabled cloud does NOT end the + search: - macOS: `~/Library/Application Support/agentfield-desktop/settings.json` - Windows: `%APPDATA%/agentfield-desktop/settings.json` - Linux: `~/.config/agentfield-desktop/settings.json` - WSL (detect: `grep -qi microsoft /proc/version`): the Linux path above first, then the Windows side, where the desktop app usually lives: `/mnt/c/Users/*/AppData/Roaming/agentfield-desktop/settings.json`. - A Linux-side file with no `cloud` key shadowing a Windows file that - holds the real cloud config is the common split-brain — the enabled - cloud wins, whichever side declares it. + A Linux-side file with no `cloud` key shadowing a Windows file that holds + the real cloud config is the common split-brain — the enabled cloud wins, + whichever side declares it. + The first file declaring `cloud.enabled: true` with a non-empty - `cloud.serverUrl` makes the cloud the target: strip any trailing slash - from the URL and take `cloud.apiKey` as the key. Health-check it + `cloud.serverUrl` makes the cloud the target: strip any trailing slash from + the URL and take `cloud.apiKey` as the key. Health-check it (`GET /health` with `X-API-Key`). - Healthy → use the cloud for everything below. - Unreachable → **stop and tell the user their cloud control plane is - configured but not responding.** Do NOT silently fall back to local: - work dispatched there lands on a different fleet with different - filesystems, which is worse than no dispatch. + configured but not responding.** Do NOT silently fall back to local: work + dispatched there lands on a different fleet with different filesystems, + which is worse than no dispatch. 3. **Otherwise use local:** `http://localhost:8080`. Then pass the target **explicitly on every call**: `af --server -k ` @@ -58,69 +112,51 @@ task; explicit per-call flags are the contract. If you'd rather not pass `-k` each time, `af auth login --server ` stores a key per server in `~/.agentfield/credentials.json`. -## MCP (zero-setup) +Health-check before the first dispatch: `curl -s /health` → `200` with +`{"status":"healthy", ...}`. Connection refused on the **local** target means no +control plane is running — the user can open AgentField Desktop, or you can start +one in the background (`af server` blocks, so background it and poll `/health`). +If the resolved target is the configured **cloud** and this fails, stop and report +it — do not retarget local. -The control plane serves a built-in **MCP server at `/mcp`** (default -`http://localhost:8080/mcp`) — same port, no extra process, on by default. If -your harness speaks MCP, this is the fastest way in. +## The golden path -Claude Code: +Five steps, CLI-first. `af` is installed wherever AgentField is; the HTTP +equivalents are further down for what the CLI can't do and as a fallback. ```bash -claude mcp add --transport http agentfield http://localhost:8080/mcp -# cloud target (§0): -claude mcp add --transport http agentfield https:///mcp --header "X-API-Key: " +af ls -e -s # 1. what can I offload to? +af call pr-af.review --schema -s # 2. the exact contract +RUN_ID=$(af call pr-af.review --in '{"pr":42}' --async -s ) # 3. dispatch +af wait "$RUN_ID" --timeout 300 -o json -s # 4. monitor +# 5. report: result + duration + cost picture + /ui/runs/$RUN_ID ``` -Other MCP clients: point them at the same streamable-HTTP URL -(`http:///mcp`, transport `http`). It's stateless JSON-RPC — no session -setup. If the server has an API key, pass it as an `X-API-Key: ` header in -the client's MCP config. +## 1. Find the subharness — discover, don't guess -Five tools are exposed: `discover_agents`, `get_reasoner_schema`, -`execute_reasoner` (starts an async run, returns a `run_id`), `get_run`, and -`wait_run`. Disable with `AGENTFIELD_MCP_ENABLED=false` (the route then 404s). - -The MCP tools cover the common discover → execute → poll loop. The `af` CLI and -the raw HTTP API below remain the full-power path (sessions, streaming, -cancel-tree, secrets, load-aware pacing); reach for them when a task needs more -than the five tools give you. - -## The flow - -0. Resolve the server (§0) — desktop-configured cloud first, explicit - `--server`/URL on every call. -1. Health-check the control plane. -2. Discover what agents and reasoners exist, and fetch the target reasoner's - exact contract before the first call. -3. Execute — async for anything nontrivial. Fire independent calls concurrently. -4. Poll (or stream) until the execution finishes — and watch for wedged runs. - -## 1. Is the control plane up? +Run this once per session and keep the roster; re-run it only when the fleet may +have changed (an install, a start, or a miss you doubt). ```bash -curl -s http://localhost:8080/health +af ls -e # entry-point reasoners only — the callable surface +af ls [query] # all reasoners across RUNNING agents (not the install registry) +af agent search "review a pull request" # BM25-ranked; --agent , --limit N (max 50) +af list # installed agents + status (source of truth for INSTALLED) ``` -Healthy: `200` with `{"status":"healthy", ...}`. Connection refused on the -**local** target means no control plane is running — the user can open the -AgentField desktop app, or you can start one in the background (`af server` -blocks, so background it and poll `/health` until healthy). If the resolved -target is the desktop-configured **cloud** and this check fails, stop and -report it (§0) — do not retarget local. +`af agent search` hits carry `reasoner_id`, `agent_id`, `invocation_target`, +`tags`, `score`, and `agent_health` — everything needed to dispatch with no second +lookup. Prefer it over dumping the whole capability payload into context once a +box has more than ~20 reasoners. -## 2. Discover agents and reasoners +The durable HTTP discovery endpoint, when you need the whole fleet at once: ```bash curl -s "http://localhost:8080/api/v1/discovery/capabilities?include_input_schema=true" ``` -This is the durable discovery endpoint. Reasoner names are `.reasoners[].id` -(NOT `.name`), and `include_input_schema=true` adds each reasoner's JSON input -schema — read it before calling so your `input` matches. - -Don't assume `jq` exists (fresh Windows boxes lack it) — parse with what's -installed, e.g.: +Reasoner names are `.reasoners[].id` (NOT `.name`). Don't assume `jq` exists +(fresh Windows boxes lack it) — parse with what's installed, e.g.: ```bash curl -s "http://localhost:8080/api/v1/discovery/capabilities?include_input_schema=true" -o caps.json @@ -133,60 +169,52 @@ for c in json.load(open('caps.json'))['capabilities'] or []: # null when no age Three gotchas: - The response's `invocation_target` field uses a **colon** (`agent:reasoner`). - The execute URL uses a **dot**. Build the target yourself: `.`. + The execute target uses a **dot**. Build it yourself: `.`. - Discovery lists **every registered agent, including dead ones** — check `health_status` and only dispatch to `"active"` agents. Dispatching to an `inactive`/`unknown` agent queues work that never runs. -- Installed-but-never-started agents may not appear at all. The local registry - is the source of truth for what's installed: `af list`, start with - `af run ` (it detaches; the agent keeps running after the CLI exits). - -### Too many reasoners to scan? Search, don't dump - -When a box has more than ~20 reasoners installed, ranked search beats reading -the whole capabilities payload into context: +- Installed-but-never-started agents may not appear at all. Discovery lists what + is RUNNING; `af list` is the source of truth for what is INSTALLED. This is the + normal first-run state, not an edge case — the desktop app ships `swe-planner` + and `pr-af` pre-provisioned but deliberately NOT started, because they need API + keys the user hasn't entered yet. + +### Start it before you dispatch — the start attempt is the diagnostic + +If the node you need is in `af list` but absent from discovery, or its +`health_status` isn't `active`, run `af run ` BEFORE dispatching (it +detaches; the agent keeps running after the CLI exits). Do this first, not as a +fallback after a failed call: a node blocked on an unset key never registers, so +every call to it comes back as the useless `agent 'X' not found`, while `af run` +names the exact variable and exits 1. + +`af run` reads the encrypted store (`~/.agentfield/secrets/*.enc`) — the same +store that gates startup — so it is the only authoritative check that a node's +keys are set. **Do not use `af doctor` or `af config --list` to decide +whether a key is configured**: doctor reads only the process environment +(`os.Getenv`) and `config --list` reads the package `.env` file, so both report a +correctly-stored key as `✗ unset`, and neither renders `require_one_of` groups. +`af secrets ls` shows what IS stored but never cross-references manifests, so it +can't tell you a required key is missing. + +**A missing key is a blocking handoff, not a problem to route around.** When +`af run` fails with -```bash -af agent search "review a pull request" # BM25-ranked; --agent , --limit N (max 50) -# or: curl -s "http://localhost:8080/api/v1/agentic/reasoners?q=review+pull+request" ``` - -Each hit carries `reasoner_id`, `agent_id`, `invocation_target`, `tags`, -`score`, and `agent_health` — everything you need to dispatch with no second -lookup. Build the execute target straight from `invocation_target` (colon → dot) -and only dispatch to hits whose `agent_health` is `"active"`. - -### Fetch the exact contract before you dispatch — never guess inputs - -Search and discovery tell you a reasoner exists; they do not license a call. -Before the first call to any reasoner, read its contract: - -```bash -af agent agent-summary --id -s # all of an agent's reasoners: descriptions + input/output schemas + health + 24h metrics -# single reasoner via MCP: get_reasoner_schema -# or the fleet at once: curl -s "/api/v1/discovery/capabilities?include_input_schema=true" +node swe-planner: missing required environment variables: OPENROUTER_API_KEY (af secrets set OPENROUTER_API_KEY --node swe-planner) ``` -Read BOTH the description and the input schema, and follow them literally: - -- A schema of `{"type":"object"}` with no properties is NOT "anything goes" — - it means the agent registered no schema and **the description text is the - entire contract**. Field names, required-ness, and types stated there are - binding (e.g. swe-pro's `code_task`: `goal` and an **absolute** `dir` are - required; model pools are comma-separated strings, not arrays). -- Result semantics live in the description too. Some agents report a failed - job in the RESULT (`status: "fail"`) while the execution itself reads - `succeeded` — check the result's own status field, not just the execution's. +— or, for an alternatives group, `at least one of ANTHROPIC_API_KEY or +OPENROUTER_API_KEY is required — set one with: …` — the value exists only in the +user's head. Stop and tell them: the exact variable(s) the error names, the exact +`af secrets set … --node ` command copied verbatim from it, and that the +same key can be entered in AgentField Desktop → Agents → → Keys. Then wait. -### Entry points only — undescribed reasoners are internal - -Agents register their internal pipeline stages alongside their public flows, -and discovery lists all of them. Dispatch ONLY to reasoners that carry the -`entrypoint` tag or a description. A reasoner with no description (e.g. -swe-planner's `run_*` stages) or tagged `internal` is plumbing invoked by an -orchestrator — calling it directly fails or corrupts a run. `af ls -e` lists -tagged entry points; when in doubt, pick the described reasoner whose -description names your use case. +Do NOT retry `af run`, do NOT dispatch to the node anyway, do NOT substitute a +different agent, and do NOT quietly do the job yourself instead — a silent +substitution is the worst outcome, because the user believes their agent ran. +Never ask the user to paste the secret value into the conversation; the CLI +prompt and the desktop form take it directly. ### No coverage: offer to build it @@ -199,8 +227,9 @@ job; a similar name or tag alone is not coverage. If discovery finds a stopped-but-capable installed agent, explain that it can be started with `af run `; do not offer a replacement build. If those checks establish that no installed reasoner supports the requested job, say explicitly: -**"No capable installed agent was found for this job."** Then offer to build the -missing capability: with the `agentfield-personal` skill when the user wants an +**"No capable installed agent was found for this job."** Then do the work inline +yourself (that is the honest fallback — say you are doing it), and offer to build +the missing capability: with the `agentfield-personal` skill when the user wants an agent installed on this machine, or with the `agentfield` skill for a standalone project repository. @@ -210,90 +239,152 @@ building an agent. Hand off to a builder skill only when the original request already authorized creating an agent, or when the user explicitly accepts this offer. -## 3. Call a reasoner +## 2. Fetch the contract -Input kwargs are ALWAYS nested under `"input"` — never raw at the top level. +### Fetch the exact contract before you dispatch — never guess inputs -**Async — the default for real work.** Returns `202` immediately: +Search and discovery tell you a reasoner exists; they do not license a call. +Before the first call to any reasoner, read its contract: ```bash -curl -s -X POST http://localhost:8080/api/v1/execute/async/swe-planner.plan \ - -H 'Content-Type: application/json' \ - -d '{"input": {"task": "add rate limiting to the API"}}' -# -> {"execution_id":"...", "run_id":"...", "status":"queued", ...} +af call . --schema # prints the input schema and exits +af agent agent-summary --id # all of an agent's reasoners: descriptions + input/output schemas + health + 24h metrics +# single reasoner via MCP: get_reasoner_schema +# or the fleet at once: curl -s "/api/v1/discovery/capabilities?include_input_schema=true" ``` -**Sync — only for calls that finish fast** (hard 90s timeout, response carries -`result` directly): +Read BOTH the description and the input schema, and follow them literally: + +- A schema of `{"type":"object"}` with no properties is NOT "anything goes" — it + means the agent registered no schema and **the description text is the + entire contract**. Field names, required-ness, and types stated there are binding + (e.g. swe-pro's `code_task`: `goal` and an **absolute** `dir` are required; + model pools are comma-separated strings, not arrays). +- Result semantics live in the description too. Some agents report a failed job + in the RESULT (`status: "fail"`) while the execution itself reads `succeeded` — + check the result's own status field, not just the execution's. + +### Entry points only — undescribed reasoners are internal + +Agents register their internal pipeline stages alongside their public flows, and +discovery lists all of them. Dispatch ONLY to reasoners that carry the +`entrypoint` tag or a description. A reasoner with no description (e.g. +swe-planner's `run_*` stages) or tagged `internal` is plumbing invoked by an +orchestrator — calling it directly fails or corrupts a run. `af ls -e` lists +tagged entry points; when in doubt, pick the described reasoner whose description +names your use case. + +## 3. Dispatch ```bash -curl -s -X POST http://localhost:8080/api/v1/execute/swe-planner.plan \ - -H 'Content-Type: application/json' \ - -d '{"input": {"task": "..."}}' +RUN_ID=$(af call swe-planner.plan --in '{"task":"add rate limiting to the API"}' --async) +# -> bare run_id on stdout; with -o json: {"run_id":"…","status":"accepted"} ``` +What `af call` does for you: it fetches the schema and **validates your input +client-side before dispatch**, so a bad payload fails locally instead of burning +a run. `--in` also takes `@file.json` / `@file.yaml`, and piping JSON to stdin +works. `--field .path.to.field` extracts a single field from a result. + +- **With `af call --in`, pass the kwargs at the top level** — the CLI wraps them + under `"input"` for you. Over raw HTTP you nest them yourself (§HTTP). +- **Always pass `--async` from a harness.** Without it, `af call` on a TTY + auto-tails the run; but a harness's stdout is *not* a TTY, and there it falls + back to the **synchronous** endpoint with its hard 90s timeout. Async + + monitor is the offload path; sync is for quick lookups only. +- If an interactive `af call` is interrupted it prints + `Detached. Resume with: af tail ` — the run is still going. + ### Concurrency — use it -Async dispatch is cheap: fire all independent calls up front, then poll them +Async dispatch is cheap: fire all independent calls up front, then monitor them together. Do NOT serialize multi-agent work — the whole point of the control -plane is managing many agents at once. When a batch of independent jobs arrives -(ten PRs to review, five repos to scan), the default is to dispatch the whole -batch now and poll as a group — not one-at-a-time. What to know: +plane is managing many subharnesses at once. When a batch of independent jobs +arrives (ten PRs to review, five repos to scan), the default is to dispatch the +whole batch now and poll as a group — not one-at-a-time. What to know: - Concurrent calls to the **same reasoner** are safe when the agent is (e.g. pr-af isolates concurrent reviews per PR). If an agent's docs don't say it's - parallel-safe, assume same-target calls may contend on shared state and - stagger them; different agents never contend. Some agents serialize ALL - executions process-wide (swe-pro queues concurrent `code_task` calls behind - one lock) — the reasoner description says so when known; dispatching more - than one heavy call to such a node just builds a queue. + parallel-safe, assume same-target calls may contend on shared state and stagger + them; different agents never contend. Some agents serialize ALL executions + process-wide (swe-pro queues concurrent `code_task` calls behind one lock) — the + reasoner description says so when known; dispatching more than one heavy call + to such a node just builds a queue. - Each call fans out inside the agent (one review ≈ dozens of sub-executions, several LLM CLI processes). 3–4 heavy runs per node is a sensible ceiling unless the agent documents otherwise. -- Save every `execution_id` you dispatch. Group related calls with an - `X-Session-ID` header so they're queryable as one batch later. +- Save every `run_id` you dispatch — you need them to monitor, to report, and for + the audit trail. Group related calls with an `X-Session-ID` header so they're + queryable as one batch later. **Check the load before piling on.** Every `af agent` / agentic response carries `meta.load`: `{running_agents, total_agents, active_executions, cpu_cores, recommended_max_concurrent}` (the recommendation is CPU-based). Read it before launching more heavy runs — if `active_executions >= recommended_max_concurrent`, -finish or await in-flight work first rather than starting more, and tell the -user you're throttling to avoid overloading the machine. +finish or await in-flight work first rather than starting more, and tell the user +you're throttling to avoid overloading the machine. **Canary after reconfiguration, then fan out.** The one exception to fire-everything-up-front: you just changed a node's runtime config (provider, model, bin path — `af secrets set` + restart). A misconfigured harness can fail *silently* — the run reports `succeeded` with empty results in seconds, and an agent that posts externally (GitHub reviews, Slack, tickets) will publish that -garbage under the user's identity, once per dispatched call. So after any -config change: send ONE representative call, confirm it did real work (nonzero -cost/duration, plausible output — not just `succeeded`), then fan out the rest -at full width. This is a gate on the first call after a config change, not a -reason to serialize steady-state work. +garbage under the user's identity, once per dispatched call. So after any config +change: send ONE representative call, confirm it did real work (plausible output, +a real `duration_ms`, and nonzero cost in the `usage/stats` window — not just +`succeeded`), then fan out the rest at full width. This is a gate on the first +call after a config change, not a reason to serialize steady-state work. + +## 4. Monitor — pick the retrieval mode + +| Situation | Do this | +|---|---| +| Short job (≤ a few minutes) | `af wait --timeout 300 -o json` — blocks until terminal, prints `{run_id, status, result}` | +| Long single job the user is watching | `af tail ` — live execution event stream (`--from N` resumes at a step) | +| Long job, or many jobs in flight | Save every run_id; poll as a group with backoff (start ~5s, settle ~30s): `af ps`, `POST /api/v1/executions/batch-status`, `GET /api/v1/executions/active` | +| Unattended service or CI — **not a coding harness** | Register a `webhook` on the execute request (below) | + +`af wait` polls `/api/v1/agentic/run/:run_id` every 2s; default `--timeout` is +600s. **Exit code 2 means TIMEOUT, not failure** — the run is still going. Wait +again with a longer timeout, or switch to group polling. Exit 1 is a genuinely +failed run. + +**Webhooks are not for you.** The execute request body (sync and async) accepts +`"webhook": {"url": "…", "secret": "…", "headers": {…}}`; the response carries +`webhook_registered` (plus `webhook_error` on async) and the execution status +carries `webhook_events`. That is for **services and CI that run an HTTP +listener**. A coding harness has no listener and must never register one and wait +— use wait / tail / poll. + +**What's in flight right now** — no IDs needed: `af ps` (`--agent `, +`--session `), or `GET /api/v1/executions/active` (filters: `?agent_id=`, +`?session_id=`), which returns per-run `active_executions`, `total_executions`, +`started_at`, `latest_activity`. -## 4. Get the result - -**What's in flight right now** — no IDs needed (also answers "how many agents -are running something"): - -```bash -curl -s http://localhost:8080/api/v1/executions/active -# {"count":2,"runs":[{"run_id":"...","target":"pr-af.review","root_status":"running", -# "active_executions":4,"total_executions":27,"started_at":"...","latest_activity":"..."}]} -``` +**Several at once:** `POST /api/v1/executions/batch-status` with +`{"execution_ids": [...]}`. Terminal entries embed the FULL result payload — +responses can be large (100KB+), so write to a file and parse from there; never +pass the response through a command-line argument (Windows caps argv ~32KB). -Filters: `?agent_id=`, `?session_id=`. CLI equivalent: `af ps`. +There is **no** `GET /api/v1/executions` list endpoint — use `/executions/active` +for in-flight work and `POST /api/v1/agentic/query` (body: +`{"resource":"runs","filters":{"status":"..."},"limit":20}`) for history. -**One execution** — poll until `status` is terminal (`succeeded` / `failed`, -also `cancelled` / `timeout`): +### Wedge protocol — "running" is not proof of progress -```bash -curl -s http://localhost:8080/api/v1/executions/ -``` +An execution can report `running` indefinitely after its agent silently dies or +deadlocks. Treat a run as suspect when `/executions/active` shows +`latest_activity` **more than ~10 minutes old** while `active_executions > 0` AND +`af logs ` shows nothing new for that run. (A quiet log alone is not proof +— one long LLM completion can be minutes of legitimate silence.) Then: -Long-running agents can take tens of minutes — poll with backoff (start ~5s, -settle at ~30s) and tell the user what is in flight. For live progress, stream -Server-Sent Events from `GET /api/v1/executions//events`. +1. Cancel the WHOLE run, not just the root: + `POST /api/v1/workflows//cancel-tree` (bottom-up, cancels children + too). Plain `/executions//cancel` cancels ONLY that execution — children + keep "running" and must be cancelled individually. +2. Restart the agent if it's wedged: `af stop && af run `. +3. Re-submit the work — and tell the user it wedged and was re-submitted. A + wedged run is a reportable event, not something to paper over. ### If the result carries a `workspace_handle`, you can read the files @@ -311,8 +402,7 @@ when it works and absent when it doesn't. `furrow` is rarely on PATH. AgentField installs it to `$AGENTFIELD_HOME/bin/` (default `~/.agentfield/bin/`), and a node that ships its own copy keeps it inside the installed package. Resolve it from those; do not try to install it -yourself. POSIX sh only — no brace expansion, so the package dirs are spelled -out. +yourself. POSIX sh only — no brace expansion, so the package dirs are spelled out. ```sh os=$(uname -s | tr A-Z a-z) @@ -354,45 +444,59 @@ so change files between issues or on a fork rather than while the agent writes. `POST /api/v1/execute/.get_workspace_handle` with `{"input":{"run_id":"..."}}`. `{"available": false}` means no mirror — carry on without it. Not every build ships this reasoner: check the agent's reasoner list (discovery or -`agent-summary`) before calling it; if it's absent, the node predates the -mirror feature and results simply never carry a handle. +`agent-summary`) before calling it; if it's absent, the node predates the mirror +feature and results simply never carry a handle. -**Several at once:** `POST /api/v1/executions/batch-status` with -`{"execution_ids": [...]}`. Terminal entries embed the FULL result payload — -responses can be large (100KB+), so write to a file and parse from there; never -pass the response through a command-line argument (Windows caps argv ~32KB). +## 5. Report back -There is **no** `GET /api/v1/executions` list endpoint — use `/executions/active` -for in-flight work and `POST /api/v1/agentic/query` (body: -`{"resource":"runs","filters":{"status":"..."},"limit":20}`) for history. +Close every offload with: the result, the run's `duration_ms`, the live URL +`/ui/runs/` (run_id URL-encoded), and — when the user would care +about spend — the cost picture. Keep the run_id in the transcript; it is the +handle for the audit trail and for any follow-up. -### Wedge protocol — "running" is not proof of progress +If the run failed, timed out, wedged, or returned an empty result on nontrivial +input: say so, show what you know (`af logs `, the error message), and ask +how to proceed. Do not fill the gap with your own inline work presented as the +subharness's. -An execution can report `running` indefinitely after its agent silently dies or -deadlocks. Treat a run as suspect when `/executions/active` shows -`latest_activity` **more than ~10 minutes old** while `active_executions > 0` -AND `af logs ` shows nothing new for that run. (A quiet log alone is not -proof — one long LLM completion can be minutes of legitimate silence.) Then: +### Cost: window aggregate, not per-run -1. Cancel the WHOLE run, not just the root: - `POST /api/v1/workflows//cancel-tree` (bottom-up, cancels children - too). Plain `/executions//cancel` cancels ONLY that execution — children - keep "running" and must be cancelled individually. -2. Restart the agent if it's wedged: `af stop && af run `. -3. Re-submit the work. +Per-execution usage (tokens, provider, model, harness, `cost_usd`) IS ingested and +stored keyed by run, but the only exposed API is the aggregate: + +```bash +curl -s "http://localhost:8080/api/ui/v1/usage/stats?window=1h" +# window=1h|24h|7d|30d|all (default 24h) -> {totals, by_model, by_provider, by_agent, by_harness} +``` + +There is **no per-run cost endpoint today.** So after finishing a batch of +offloaded work, when the user would care, report the cost picture from +`usage/stats` — e.g. the 1h window's `by_agent` entry for the node you used — +stating plainly that it is a window aggregate for that agent, not an exact +per-run figure. Never invent a per-run number by dividing or estimating. +`duration_ms` IS exact and per-execution (it is in the execute and status +responses). **Duration is per-run truth; cost is window truth.** ## Sessions and multi-call work - `X-Session-ID: ` on execute requests groups multi-turn work; the control plane forwards it to the agent and scopes session memory by it. -- Reuse `X-Run-ID` across several execute calls to group them into one - workflow; each response also returns its `run_id`. +- Reuse `X-Run-ID` across several execute calls to group them into one workflow; + each response also returns its `run_id`. Agents share state through control-plane memory if you need to pass artifacts around: `POST /api/v1/memory/set` with `{"key": ..., "data": , "scope": "global"}` and `POST /api/v1/memory/get` with `{"key": ...}` (non-global scopes resolve from the `X-Workflow-ID` / `X-Session-ID` / `X-Actor-ID` headers). +## Audit trail + +Every execution is recorded — that is part of what makes offloading better than +inline work. When provenance matters (or the user asks "what did the agents +actually do"), fetch the verifiable-credential chain for a workflow: +`GET /api/v1/did/workflow//vc-chain` (available when DID/VC is enabled), +and verify offline with `af verify audit.json`. + ## When things fail | Symptom | Meaning | Fix | @@ -401,10 +505,75 @@ resolve from the `X-Workflow-ID` / `X-Session-ID` / `X-Actor-ID` headers). | desktop-configured cloud unreachable | cloud deployment down, or URL/key stale | stop and tell the user (§0) — never silently retarget local | | 401/403 from a cloud target | missing or wrong `X-API-Key` | key from desktop `settings.json` `cloud.apiKey`, or `af auth login --server ` | | agent `inactive` in discovery / missing | node installed but not running (or not installed) | `af list`, then `af run ` — or `af install ` | -| `missing required environment variables: X` from `af run` | required key not configured | `af secrets set X` (value via stdin/arg; `--node ` for node-scoped) — or desktop app → Agents → Keys | +| HTTP **400** `{"error":"agent 'X' not found","error_category":"internal_error"}` | the node never registered — usually installed but not started (it's 400, not 404) | `af list` → `af run ` → read the error it prints → hand off if it's a missing key | +| MCP: `target "X.y" not found. Call discover_agents to list available agents and reasoners.` | same cause, seen through MCP | same path: `af list` → `af run ` → hand off | +| `missing required environment variables: X` from `af run` | required key not configured; the node cannot start | **stop and hand off** — give the user the `af secrets set X --node ` line verbatim, or desktop → Agents → → Keys. Never retry, substitute another agent, or do the work yourself | +| `af doctor` / `af config --list` reports a key as unset | they read `os.Getenv` and the package `.env`, not the encrypted store | ignore them for this question — `af run ` is the only authoritative check | +| `af wait` exits **2** | TIMEOUT, not failure — the run is still going | wait again with a longer `--timeout`, or switch to `af tail` / group polling. Exit 1 is the real failure | +| `af call` fails locally before dispatch | client-side schema validation rejected your input | re-read `af call --schema`; fix the payload — nothing was queued | | HTTP 502 with `error_message` | the agent itself errored | read `af logs `, fix, retry | -| execution `running` but latest_activity stale & logs quiet | wedged run | wedge protocol above: cancel-tree → restart agent → re-submit | -| result claims success with zero findings/output on nontrivial input | possible silent tool failure inside the agent | check `af logs ` for that run before trusting it | +| execution `running` but latest_activity stale & logs quiet | wedged run | wedge protocol above: cancel-tree → restart agent → re-submit, and tell the user | +| result claims success with zero findings/output on nontrivial input | possible silent tool failure inside the agent | check `af logs ` for that run before trusting it — and report it, don't redo it silently | + +## MCP (zero-setup) + +The control plane serves a built-in **MCP server at `/mcp`** (default +`http://localhost:8080/mcp`) — same port, no extra process, on by default. If +your harness speaks MCP, this is the fastest way in. + +```bash +claude mcp add --transport http agentfield http://localhost:8080/mcp +# cloud target (§0): +claude mcp add --transport http agentfield https:///mcp --header "X-API-Key: " +``` + +Other MCP clients: point them at the same streamable-HTTP URL +(`http:///mcp`, transport `http`). It's stateless JSON-RPC — no session +setup. If the server has an API key, pass it as an `X-API-Key: ` header in +the client's MCP config. + +Five tools are exposed: `discover_agents`, `get_reasoner_schema`, +`execute_reasoner` (starts an async run, returns a `run_id`), `get_run`, and +`wait_run`. Disable with `AGENTFIELD_MCP_ENABLED=false` (the route then 404s). + +The MCP tools cover the common discover → execute → poll loop. The `af` CLI and +the raw HTTP API remain the full-power path (sessions, streaming, cancel-tree, +secrets, load-aware pacing); reach for them when a task needs more than the five +tools give you. + +## HTTP API — where the CLI can't reach + +Use these for webhook registration, batch status, memory, cancel-tree, and +`X-Session-ID`/`X-Run-ID` headers — or as the whole path when `af` isn't +installed. **Over raw HTTP, input kwargs are ALWAYS nested under `"input"`** — +never raw at the top level. Empty input is `{"input": {}}`. + +```bash +# async — the default for real work; returns 202 immediately +curl -s -X POST http://localhost:8080/api/v1/execute/async/swe-planner.plan \ + -H 'Content-Type: application/json' \ + -H 'X-Session-ID: my-batch-1' \ + -d '{"input": {"task": "add rate limiting to the API"}}' +# -> {"execution_id":"...", "run_id":"...", "status":"queued", ...} + +# sync — quick lookups only (hard 90s timeout; response carries result + duration_ms) +curl -s -X POST http://localhost:8080/api/v1/execute/swe-planner.plan \ + -H 'Content-Type: application/json' -d '{"input": {"task": "..."}}' + +# one execution — poll until status is terminal (succeeded/failed/cancelled/timeout) +curl -s http://localhost:8080/api/v1/executions/ +# live progress as Server-Sent Events +curl -s http://localhost:8080/api/v1/executions//events + +# service/CI only: register a webhook at dispatch time +curl -s -X POST http://localhost:8080/api/v1/execute/async/pr-af.review \ + -H 'Content-Type: application/json' \ + -d '{"input":{"pr":42},"webhook":{"url":"https://ci.example/hook","secret":"s3cr3t"}}' +``` + +Batched API reads in one round trip: `POST /api/v1/agentic/batch` with +`{"operations":[{"id":"op1","method":"GET","path":"/api/v1/agentic/status"}]}` +(CLI: `af agent batch -f operations.json`). ## Local ops cheat sheet (af CLI) @@ -415,39 +584,53 @@ every invocation when the resolved target is the cloud (§0). af list # installed agents + status af ls [query] # search reasoners across running agents (NOT the install registry) af ls -e # only entry-point reasoners — the callable surface +af agent search "" # ranked reasoner search af agent agent-summary --id # full contract: reasoners, schemas, health, 24h metrics +af call . --schema # input schema only +af call . --in '' --async # dispatch; prints run_id +af wait [--timeout N] # block until terminal (exit 2 = timeout) +af tail # attach to the live event stream af ps # in-flight runs across all agents (af ps --agent ) af run # start (detached); af stop af logs # agent logs (-f follows; no per-run filter — grep by run_id) -af secrets set KEY # store an API key (encrypted; prompts for value) +af secrets set KEY [--node ] # store an API key (encrypted; prompts for value) af secrets ls # what's configured (values never shown) af install # install a new agent node ``` -## Audit trail - -Every execution is recorded. When provenance matters (or the user asks "what -did the agents actually do"), fetch the verifiable-credential chain for a -workflow: `GET /api/v1/did/workflow//vc-chain` (available when DID/VC -is enabled), and verify offline with `af verify audit.json`. - ## Hard rules +- **Offload by default.** Any task an installed subharness covers goes to that + subharness — whatever its size — announced with its `/ui/runs/` + link, not offered as an option and not done inline by habit. Coverage is the + test; check it (cheaply, once per session) before doing the work yourself. +- **Never silent-wash an offload.** A failed, stalled, or empty run is reported + and asked about. Never redo it inline and present it as the subharness's work, + and never substitute your own work for an agent that can't start. +- Say "subharness" to the user; keep `agent` / `reasoner` / `node` in commands, + fields, and anything the user has to type. - Resolve the server per §0 and pass it explicitly (`--server` / full URL) on - every call. A desktop-configured cloud beats the local default; an - unreachable configured cloud is a stop-and-report, never a silent fallback. -- Fetch the reasoner's contract before the first call. A vacuous schema means - the description is the contract — follow it literally. + every call. A desktop-configured cloud beats the local default; an unreachable + configured cloud is a stop-and-report, never a silent fallback. +- Fetch the reasoner's contract before the first call. A vacuous schema means the + description is the contract — follow it literally. - Dispatch only to `entrypoint`-tagged or described reasoners. Undescribed or `internal`-tagged reasoners are pipeline stages — never call them directly. - Every call goes through the control plane — never POST to an agent's own port. The one exception is a `workspace_handle`: its `ssh://` endpoint is a furrow transport, not the agent's HTTP port, and the per-run token in the handle is what authorizes it. Reading files there is not an agent call. -- Kwargs live under `"input"`. Empty input is `{"input": {}}`. -- Async + poll for anything that might exceed a few seconds; sync is for quick - lookups only. Independent async calls go out together, not one at a time. -- Only dispatch to agents whose discovery `health_status` is `"active"`. +- Over HTTP, kwargs live under `"input"` (`{"input": {}}` when empty). With + `af call --in`, pass them at the top level — the CLI nests them. +- `--async` + monitor for anything that might exceed a few seconds; sync is for + quick lookups only. Independent calls go out together, not one at a time. +- Never register a webhook and wait for it — a coding harness has no listener. +- Only dispatch to agents whose discovery `health_status` is `"active"`. If it + isn't there, `af run ` first — and if that reports a missing required + environment variable, stop and hand off to the user with the exact key name and + `af secrets set` command. Never retry it, work around it, or substitute. +- Report duration from `duration_ms` (exact) and cost from `usage/stats` (a + window aggregate). Never state a per-run cost — there is no such endpoint. - Don't guess endpoints. The surface above is the contract; if something is missing, ask `GET /api/v1/agentic/discover?q=` before inventing a route. - Building or modifying an agent (new reasoners, scaffolds, deploys) is the diff --git a/control-plane/internal/skillkit/skill_mirror_test.go b/control-plane/internal/skillkit/skill_mirror_test.go index ff700d232..abb4c109c 100644 --- a/control-plane/internal/skillkit/skill_mirror_test.go +++ b/control-plane/internal/skillkit/skill_mirror_test.go @@ -21,7 +21,7 @@ func TestSkillCatalogAndEmbeddedMirrorsStayAligned(t *testing.T) { }{ {name: "agentfield", version: "0.5.2"}, {name: "agentfield-personal", version: "0.1.0"}, - {name: "agentfield-use", version: "0.6.0"}, + {name: "agentfield-use", version: "0.8.0"}, } for _, tt := range tests { diff --git a/desktop/README.md b/desktop/README.md index 6182629a9..87e8e2c34 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -15,11 +15,16 @@ navigation, light/dark from the OS. - `running` — registry says running and the control plane sees the node - `stopped` — registry says stopped and the control plane does not see it - `unknown` — registry and control plane disagree (stale registry / conflict) + + The nodes that ship with the app (`src/shared/bundled.ts`) appear here from + the first launch, above the installed ones, as provisioning rows carrying + live install output — so a new user watches their agents arrive in the + library rather than being handed an empty screen and a marketplace. - **Activity** — in-flight workflow runs (live pulse) and a short tail of finished ones, from `GET /api/ui/v2/workflow-runs`. -- **Install** — a curated, hard-coded catalog (`src/shared/catalog.ts`) - plus an **Install from repository** field for pasting any GitHub repo that - hosts an installable node (`https://github.com//`, or +- **Install** — the Agents view's add-mode: a curated, hard-coded catalog + (`src/shared/catalog.ts`) plus an **Install from repository** field for + pasting any GitHub repo that hosts an installable node (`https://github.com//`, or `…///` to pick one node out of a multi-node repo). Both shell out to `af install ` and stream progress lines into the row; the af CLI stays the single contract for installs. Catalog entries are keyed by the @@ -30,6 +35,14 @@ navigation, light/dark from the OS. and it is validated there to an `https://github.com/…` shape before spawn (`parseRepoSource` in `src/main/installer.ts`) — no other host or scheme, and never a value that could be read as a CLI flag. + + The catalog holds only what a user chooses to add. Nodes that ship with the + app live in `src/shared/bundled.ts` and are deliberately absent from these + cards — they arrive on their own, so offering them as an install would be a + button that does nothing. `catalogEntry()` still resolves over both lists, + which is what keeps a bundled node updatable and reinstallable from its + Agents row without widening the "renderer only ever sends a vetted name" + guard. - **Settings** — the "set it and forget it" surface: open at login (hidden, tray-only, via an OS login item), start the control plane automatically, and pick which agents auto-start. Persisted to `settings.json` in the @@ -52,6 +65,80 @@ The control-plane probe only trusts `/health` responses that look like AgentField's payload — an unrelated service on port 8080 renders as "Port in use", never as a running control plane. +## Bundled agent nodes + +The app ships with agents. `src/shared/bundled.ts` names them +(`swe-planner` from `Agent-Field/SWE-AF`, `pr-af` from `Agent-Field/pr-af`), +and `src/main/bundledAgents.ts` provisions them on first launch: after +autostart has a control plane up, each missing node is installed in sequence +through the same control-plane install API the Install view drives, sharing +the same single-install mutex so a user-initiated install can never collide +with it. Provisioning targets the local control plane only and is skipped while +a cloud control plane is active. Nodes that were already installed are adopted +so uninstalling them sticks, and a control plane without the install API is +skipped instead of producing install errors on every launch. + +Delivery is fetch-on-first-launch, not baked into the installer. The DMG and +the NSIS package carry no agent payload — that keeps the download small, and +it means a bundled node picks up its latest release the first time a user +opens the app instead of being frozen at whatever was current when the app +was built. + +Both are named at their **bare repo URL**, never at `//go`. Each repo's root +manifest declares `superseded_by:` pointing at its Go node, and that redirect +is what carries a user who already has the older Python node across: it +installs the successor, migrates node-scoped secrets, and retires the old +package. Naming `//go` here would install the same code and skip all of that. +`swe-planner` also brings the vendored `swe-pro` coding engine with it +(`SWE_PRO_ENGINE` defaults on in its manifest). + +Three rules make this safe to run on every launch: + +- **Uninstall sticks.** A provisioned name is recorded in + `settings.provisionedBundled` and never auto-installed again. Without that, + removing a bundled agent would silently undo itself on the next start. +- **Failure retries.** A failed install is not recorded, so the next launch + tries again — and the failure stays visible as a row rather than vanishing. +- **Nothing is started.** Both nodes require API keys a first-launch user has + not entered yet, so provisioning installs and stops there; the name is added + to `autostartAgents` and the row's existing **Needs keys** chip asks for what + is missing. Starting into a guaranteed "missing required environment + variables" failure would teach a new user that their agents are broken. + +`AGENTFIELD_SKIP_BUNDLED=1` disables provisioning entirely. + +### Telling the user a key is missing + +An installed agent that cannot start is invisible where it matters most: it +never registers with the control plane, so a coding agent calling it gets +`HTTP 400 agent 'swe-planner' not found` with no hint that the cause was an +unset key. Three surfaces close that gap, each covering a case the others +cannot: + +- **`src/main/keyNotice.ts`** fires one native notification when provisioning + finishes and a node it just installed still has unresolved required keys — + the only signal that reaches a user whose app launched hidden at login. + Scoped to the names installed by that run and recorded in + `settings.keyNoticeShown`, so it never nags; a bundled node added in a later + release still gets its own notice. +- **`components/KeysBanner.tsx`** names the blocked agents across the top of + the window for as long as they are blocked. Not dismissible: unlike the + update banner it does not advertise something optional, it reports that + installed agents cannot run. It is self-clearing, and uninstalling the + agents removes it honestly. +- **The `agentfield-use` skill** (repo root `skills/`) tells Claude Code and + Codex to run `af run ` before dispatching to a node that is installed + but absent from discovery, and to treat the resulting + `missing required environment variables:` as a blocking handoff — name the + key, hand over the `af secrets set` line, and never retry or substitute + another agent. + +All three read the same authority: `getEnvReports()` → +`GET /api/ui/v1/agents/:agentId/secrets?include=env` → the encrypted store +that actually gates `af run`. Note that `af doctor` and `af config --list` do +**not** — they read the process environment and the package `.env` file +respectively, and will report a correctly-stored key as unset. + ## Agent keys (secrets) Agents declare the environment they need (API keys, tokens) in their @@ -208,7 +295,16 @@ Packaging is unsigned for now (no notarization/signing identities configured). `nodeIntegration: false`, `sandbox: true`. The preload (`src/preload/index.ts`) exposes a small typed API via `contextBridge`. - **Shared IPC types** live in `src/shared/types.ts`; the install catalog in - `src/shared/catalog.ts`; deep-link parsing in `src/shared/deeplink.ts`. + `src/shared/catalog.ts`; the nodes that ship with the app in + `src/shared/bundled.ts`; deep-link parsing in `src/shared/deeplink.ts`. +- **First-launch provisioning** is `src/main/bundledAgents.ts`: a pure + `planBundledInstalls()` (what to install, given the registry, the + already-provisioned set, the resolved CLI, and whether the control plane is + up) plus an effectful runner driven by injected deps — the same + planner/runner split as `src/main/aforge-companion.ts`, so the decision is + unit-tested without ever spawning an install. Progress rides the existing + 5-second snapshot as `bundled: BundledStatus[]`, so the renderer needs no + second polling loop. - **Tray presentation logic** (state/labels/glyph selection) is pure in `src/main/tray-model.ts` (unit-tested); the Electron glue is `src/main/tray.ts`. - **Mac-first chrome** in `src/main/index.ts`: `titleBarStyle: hiddenInset` + diff --git a/desktop/src/main/agentfield.test.ts b/desktop/src/main/agentfield.test.ts index 7ba943702..474ef4890 100644 --- a/desktop/src/main/agentfield.test.ts +++ b/desktop/src/main/agentfield.test.ts @@ -1,6 +1,7 @@ import os from 'node:os' import path from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' +import type { BundledStatus } from '../shared/types' import type { CpClient, PackageInfo } from './cpClient' import { DEFAULT_BASE_URL, @@ -19,6 +20,7 @@ import { } from './agentfield' import { DEFAULT_CONTROL_PLANE_PORT } from './ports' import { installCommand, sanitizeInstallOutput } from './installer' +import { BUNDLED_NODES } from '../shared/bundled' import { CATALOG, catalogEntry } from '../shared/catalog' import { setCloudConnection, setLocalApiKey } from './connection' @@ -566,19 +568,25 @@ describe('install catalog', () => { // A repo that ships both a Python node and its Go counterpart is offered as // a single install, named for the product and sourced at the bare repo URL — // the root manifest's `superseded_by:` redirect decides which node lands and - // carries an existing install across. A second row for the same repo, or the - // old implementation-suffixed name creeping back in, must fail here rather - // than quietly reappear in the Install view. + // carries an existing install across. Both such products now SHIP WITH the + // app (shared/bundled.ts) and are provisioned on first launch, so the + // invariant lives there now: a second entry for the same repo, the old + // implementation-suffixed name creeping back in, or either product + // reappearing as a marketplace card must fail here rather than quietly + // return to the Install view. it.each([ { repo: 'Agent-Field/SWE-AF', name: 'swe-planner', retired: 'swe-planner-go' }, { repo: 'Agent-Field/pr-af', name: 'pr-af', retired: 'pr-af-go' } - ])('offers $name as one product-named entry sourced at the bare repo', (tc) => { - const entries = CATALOG.filter((e) => e.source.includes(tc.repo)) + ])('ships $name as one product-named bundled node sourced at the bare repo', (tc) => { + const entries = BUNDLED_NODES.filter((e) => e.source.includes(tc.repo)) expect(entries).toHaveLength(1) expect(entries[0].name).toBe(tc.name) expect(entries[0].source).toBe(`https://github.com/${tc.repo}`) expect(entries[0].language).toBe('go') - expect(CATALOG.map((e) => e.name)).not.toContain(tc.retired) + expect([...CATALOG, ...BUNDLED_NODES].map((e) => e.name)).not.toContain(tc.retired) + expect(CATALOG.map((e) => e.name)).not.toContain(tc.name) + // Still installable and --force updatable from the Agents view. + expect(catalogEntry(tc.name)).toEqual(entries[0]) }) }) @@ -731,6 +739,29 @@ describe('getSnapshot', () => { expect(requested.some((url) => url.includes('/usage/stats'))).toBe(false) }) + // Bundled provisioning rows are main-process state (main/bundledAgents.ts), + // so getSnapshot only passes them through — same contract as skillSync. + it('carries the bundled provisioning rows, defaulting to none', async () => { + const fetchImpl: FetchLike = async () => { + throw new TypeError('fetch failed') + } + const bundled: BundledStatus[] = [ + { + name: 'swe-planner', + description: 'Software factory', + language: 'go', + phase: 'installing', + message: 'Cloning…' + } + ] + + const without = await getSnapshot({ cpClient: packagesClient(), fetchImpl }) + expect(without.bundled).toEqual([]) + + const with_ = await getSnapshot({ cpClient: packagesClient(), fetchImpl, bundled }) + expect(with_.bundled).toEqual(bundled) + }) + it('reports an unreachable control plane and an absent registry gracefully', async () => { const fetchImpl: FetchLike = async () => { throw new TypeError('fetch failed') diff --git a/desktop/src/main/agentfield.ts b/desktop/src/main/agentfield.ts index 869863e4a..52c7e2e39 100644 --- a/desktop/src/main/agentfield.ts +++ b/desktop/src/main/agentfield.ts @@ -8,6 +8,7 @@ import path from 'node:path' import type { AgentBadge, AgentFieldSnapshot, + BundledStatus, ControlPlaneStatus, DashboardMetrics, ExecutionsResult, @@ -361,6 +362,12 @@ export interface SnapshotOptions { * IPC handler supplies it; callers that don't care (autostart) leave it out. */ skillSync?: SkillSyncRecord | null + /** + * First-launch provisioning rows for the bundled nodes, passed through the + * same way as skillSync: it is main-process state (main/bundledAgents.ts), + * so the IPC handler supplies it and callers that don't care omit it. + */ + bundled?: BundledStatus[] } /** @@ -404,6 +411,7 @@ export async function getSnapshot(options: SnapshotOptions = {}): Promise { }) }) }) + +describe('serverSpawnEnv', () => { + it('pins the port and tells the server its own URL for the agents it starts', () => { + expect(serverSpawnEnv(8080)).toEqual({ + AGENTFIELD_PORT: '8080', + AGENTFIELD_SERVER: 'http://localhost:8080' + }) + expect(serverSpawnEnv(18480)).toEqual({ + AGENTFIELD_PORT: '18480', + AGENTFIELD_SERVER: 'http://localhost:18480' + }) + }) +}) diff --git a/desktop/src/main/agents.ts b/desktop/src/main/agents.ts index 7fccae7f4..a712b2512 100644 --- a/desktop/src/main/agents.ts +++ b/desktop/src/main/agents.ts @@ -178,6 +178,26 @@ function realRunCommand(command: string, args: string[]): Promise { * (the same file the macOS launchd agent uses). The returned promise resolves * only if the spawn itself errors; otherwise it stays pending. */ +/** + * Environment pinned onto a spawned `af server`. + * + * AGENTFIELD_PORT pins the server to the port this app will poll. Without + * it, an agentfield.yaml that sets its own port makes `af server` bind there + * while the app waits on the chosen port forever — a healthy server and a + * spinner that never resolves. + * + * AGENTFIELD_SERVER is the URL the server hands to every agent it starts + * (control-plane resolveServerURL reads its own environment and otherwise + * falls back to http://localhost:8080 regardless of the port it listens on). + * Without it, a control plane on any other port — the auto-picked one when + * 8080 is busy, or a configured one — tells swe-planner, pr-af and every + * other node to register with localhost:8080, i.e. with whatever else is + * there, or nothing. + */ +export function serverSpawnEnv(port: number): NodeJS.ProcessEnv { + return { AGENTFIELD_PORT: String(port), AGENTFIELD_SERVER: `http://localhost:${port}` } +} + function defaultSpawnServer(port: number): Promise { return new Promise((resolve) => { let log: number @@ -189,15 +209,11 @@ function defaultSpawnServer(port: number): Promise { resolve({ ok: false, message: `could not open control-plane log: ${String(err)}` }) return } - // Pin the spawned server to the port this app will poll. Without it, an - // agentfield.yaml that sets its own port makes `af server` bind there - // while the app waits on the chosen port forever — a healthy server and - // a spinner that never resolves. const child = spawn(getCliCommand(), ['server'], { windowsHide: true, detached: true, stdio: ['ignore', log, log], - env: childEnv({ AGENTFIELD_PORT: String(port) }) + env: childEnv(serverSpawnEnv(port)) }) child.on('error', (err: NodeJS.ErrnoException) => { resolve({ diff --git a/desktop/src/main/autostart.test.ts b/desktop/src/main/autostart.test.ts index 37857579a..5ab6a3ea4 100644 --- a/desktop/src/main/autostart.test.ts +++ b/desktop/src/main/autostart.test.ts @@ -61,11 +61,13 @@ function settings(overrides: Partial): DesktopSettings { localApiKey: '', lastControlPlanePort: null, autostartAgents: [], + provisionedBundled: [], installSkills: true, trayCompanion: true, dismissedUpdateVersion: null, starPrompt: 'pending', starPromptSnoozedUntil: null, + keyNoticeShown: [], ...overrides } } diff --git a/desktop/src/main/bundledAgents.test.ts b/desktop/src/main/bundledAgents.test.ts new file mode 100644 index 000000000..e433978ed --- /dev/null +++ b/desktop/src/main/bundledAgents.test.ts @@ -0,0 +1,364 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { BUNDLED_NODES, bundledEntry, isBundled } from '../shared/bundled' +import { CATALOG, catalogEntry } from '../shared/catalog' +import type { InstallResult } from '../shared/types' +import { + type BundledDeps, + type BundledPlanInput, + bundledStatuses, + ensureBundledAgents, + planBundledInstalls, + resetBundledState +} from './bundledAgents' + +const NAMES = BUNDLED_NODES.map((entry) => entry.name) + +const baseInput: BundledPlanInput = { + installed: [], + provisioned: [], + skipEnv: undefined, + cliCommand: '/managed/af', + cloudActive: false, + controlPlaneReachable: true, + registryReadable: true +} + +describe('BUNDLED_NODES', () => { + it('ships swe-planner and pr-af as go nodes sourced at the bare repo', () => { + expect(NAMES).toEqual(['swe-planner', 'pr-af']) + for (const entry of BUNDLED_NODES) { + expect(entry.name).toMatch(/^[a-z0-9][a-z0-9-]*$/) + expect(entry.description.length).toBeGreaterThan(0) + expect(entry.language).toBe('go') + // The bare repo URL, never the //go selector: the root manifest's + // superseded_by redirect is what migrates an older install. + expect(entry.source).toMatch(/^https:\/\/github\.com\/Agent-Field\/[A-Za-z-]+$/) + } + }) + + it('are not marketplace rows, but stay resolvable for install/update', () => { + for (const name of NAMES) { + expect(CATALOG.map((entry) => entry.name)).not.toContain(name) + expect(catalogEntry(name)).toEqual(bundledEntry(name)) + expect(isBundled(name)).toBe(true) + } + expect(isBundled('sec-af')).toBe(false) + expect(bundledEntry('definitely-not-real')).toBeUndefined() + }) +}) + +describe('planBundledInstalls', () => { + it('installs every bundled node on a clean first launch', () => { + expect(planBundledInstalls(baseInput)).toEqual({ + install: NAMES, + adopt: [], + reason: `provisioning bundled nodes: ${NAMES.join(', ')}` + }) + }) + + it('skips only when AGENTFIELD_SKIP_BUNDLED is exactly 1', () => { + expect(planBundledInstalls({ ...baseInput, skipEnv: '1' })).toEqual({ + install: [], + adopt: [], + reason: 'AGENTFIELD_SKIP_BUNDLED=1 — skipping bundled nodes' + }) + expect(planBundledInstalls({ ...baseInput, skipEnv: '0' }).install).toEqual(NAMES) + expect(planBundledInstalls({ ...baseInput, skipEnv: '' }).install).toEqual(NAMES) + }) + + it('skips when the CLI is null, empty, or whitespace', () => { + for (const cliCommand of [null, '', ' ']) { + expect(planBundledInstalls({ ...baseInput, cliCommand })).toEqual({ + install: [], + adopt: [], + reason: 'no usable af CLI — skipping bundled nodes' + }) + } + }) + + it('skips while the control plane is unavailable', () => { + expect(planBundledInstalls({ ...baseInput, controlPlaneReachable: false })).toEqual({ + install: [], + adopt: [], + reason: 'control plane unavailable — skipping bundled nodes' + }) + }) + + it('applies skip env, missing-CLI, then control-plane precedence', () => { + expect( + planBundledInstalls({ + ...baseInput, + skipEnv: '1', + cliCommand: null, + controlPlaneReachable: false + }).reason + ).toContain('AGENTFIELD_SKIP_BUNDLED') + expect( + planBundledInstalls({ ...baseInput, cliCommand: null, controlPlaneReachable: false }).reason + ).toBe('no usable af CLI — skipping bundled nodes') + }) + + it('skips cloud control planes before consulting reachability or the registry', () => { + const plan = planBundledInstalls({ + ...baseInput, + cloudActive: true, + controlPlaneReachable: false, + registryReadable: false + }) + expect(plan.install).toEqual([]) + expect(plan.adopt).toEqual([]) + expect(plan.reason).toContain('cloud') + }) + + it('skips when the installed-agent registry could not be read', () => { + const plan = planBundledInstalls({ ...baseInput, registryReadable: false }) + expect(plan.install).toEqual([]) + expect(plan.adopt).toEqual([]) + expect(plan.reason).toContain('registry') + }) + + it('leaves alone nodes already in the registry', () => { + expect(planBundledInstalls({ ...baseInput, installed: [NAMES[0]] })).toMatchObject({ + adopt: [NAMES[0]], + install: [NAMES[1]] + }) + }) + + // Uninstalling a bundled node must stick: it is in provisionedBundled but no + // longer in the registry, and it must not come back on the next launch. + it('never re-installs a node already provisioned once', () => { + expect(planBundledInstalls({ ...baseInput, provisioned: [NAMES[0]] }).install).toEqual([ + NAMES[1] + ]) + expect(planBundledInstalls({ ...baseInput, provisioned: NAMES })).toEqual({ + install: [], + adopt: [], + reason: 'bundled nodes already provisioned' + }) + }) + + it('does not reinstall nodes removed after adoption', () => { + expect(planBundledInstalls({ ...baseInput, provisioned: NAMES })).toMatchObject({ + install: [], + adopt: [] + }) + }) + + it('does not adopt an already-recorded installed node twice', () => { + expect( + planBundledInstalls({ ...baseInput, installed: [NAMES[0]], provisioned: [NAMES[0]] }) + ).toMatchObject({ adopt: [], install: [NAMES[1]] }) + }) +}) + +function fakeDeps( + results: Record = {} +): BundledDeps & { + install: ReturnType + markProvisioned: ReturnType + onInstalled: ReturnType + lines: string[] +} { + const lines: string[] = [] + return { + install: vi.fn(async (name: string) => results[name] ?? { ok: true, message: `${name} installed` }), + markProvisioned: vi.fn(async () => {}), + onInstalled: vi.fn(async () => {}), + log: (message: string) => lines.push(message), + lines + } +} + +describe('ensureBundledAgents', () => { + beforeEach(() => resetBundledState()) + + it('installs every planned node in order, sequentially', async () => { + const order: string[] = [] + const deps = fakeDeps() + let inFlight = 0 + deps.install.mockImplementation(async (name: string) => { + // The control-plane install API answers a concurrent install with 409, + // so the runner must never have two in flight. + expect(inFlight).toBe(0) + inFlight += 1 + await Promise.resolve() + inFlight -= 1 + order.push(name) + return { ok: true, message: `${name} installed` } + }) + + await ensureBundledAgents(baseInput, deps) + + expect(order).toEqual(NAMES) + expect(deps.markProvisioned.mock.calls.map((c) => c[0])).toEqual(NAMES) + expect(deps.onInstalled.mock.calls.map((c) => c[0])).toEqual(NAMES) + // No phantom rows for nodes the registry now lists. + expect(bundledStatuses()).toEqual([]) + }) + + it('seeds a pending row for every planned node before installing', async () => { + const deps = fakeDeps() + const seen: ReturnType[] = [] + deps.install.mockImplementation(async (name: string) => { + seen.push(bundledStatuses()) + return { ok: true, message: `${name} installed` } + }) + + await ensureBundledAgents(baseInput, deps) + + expect(seen[0].map((s) => s.name)).toEqual(NAMES) + expect(seen[0][0]).toMatchObject({ phase: 'installing', message: '' }) + expect(seen[0][1]).toMatchObject({ phase: 'pending', message: '' }) + expect(seen[0][0].description).toBe(BUNDLED_NODES[0].description) + expect(seen[0][0].language).toBe('go') + }) + + it('shows the latest streamed line as the row message while installing', async () => { + const deps = fakeDeps() + let mid: ReturnType = [] + deps.install.mockImplementation(async (name: string, onLine: (line: string) => void) => { + onLine('cloning') + onLine('building') + mid = bundledStatuses() + return { ok: true, message: `${name} installed` } + }) + + await ensureBundledAgents({ ...baseInput, installed: [NAMES[1]] }, deps) + + expect(mid).toHaveLength(1) + expect(mid[0]).toMatchObject({ name: NAMES[0], phase: 'installing', message: 'building' }) + }) + + it('keeps a failed row for the session and does not mark it provisioned', async () => { + const deps = fakeDeps({ [NAMES[0]]: { ok: false, message: 'clone failed' } }) + + await ensureBundledAgents(baseInput, deps) + + expect(bundledStatuses()).toEqual([ + { + name: NAMES[0], + description: BUNDLED_NODES[0].description, + language: 'go', + phase: 'failed', + message: 'clone failed' + } + ]) + // The failure must not stop the next node, and must not be recorded — + // that is what makes the next launch retry it. + expect(deps.markProvisioned.mock.calls.map((c) => c[0])).toEqual([NAMES[1]]) + expect(deps.install).toHaveBeenCalledTimes(2) + expect(deps.lines.some((l) => l.includes('clone failed'))).toBe(true) + }) + + it('treats a rejecting installer as a failed install rather than throwing', async () => { + const deps = fakeDeps() + deps.install.mockRejectedValueOnce(new Error('install exploded')) + + await expect(ensureBundledAgents(baseInput, deps)).resolves.toBeUndefined() + + expect(bundledStatuses()).toHaveLength(1) + expect(bundledStatuses()[0]).toMatchObject({ name: NAMES[0], phase: 'failed' }) + expect(bundledStatuses()[0].message).toContain('install exploded') + }) + + it('still counts an install that persisted or post-install steps could not follow', async () => { + const deps = fakeDeps() + deps.markProvisioned.mockRejectedValue(new Error('disk full')) + deps.onInstalled.mockRejectedValue(new Error('autostart failed')) + + await expect(ensureBundledAgents(baseInput, deps)).resolves.toBeUndefined() + + expect(deps.install).toHaveBeenCalledTimes(2) + expect(bundledStatuses()).toEqual([]) + expect(deps.lines.some((l) => l.includes('disk full'))).toBe(true) + expect(deps.lines.some((l) => l.includes('autostart failed'))).toBe(true) + }) + + it('does nothing but log when the plan is empty', async () => { + const deps = fakeDeps() + await ensureBundledAgents({ ...baseInput, skipEnv: '1' }, deps) + expect(deps.install).not.toHaveBeenCalled() + expect(bundledStatuses()).toEqual([]) + expect(deps.lines).toEqual(['bundled: AGENTFIELD_SKIP_BUNDLED=1 — skipping bundled nodes']) + }) + + it('adopts installed nodes without installing or creating status rows', async () => { + const deps = fakeDeps() + await ensureBundledAgents({ ...baseInput, installed: NAMES }, deps) + + expect(deps.markProvisioned.mock.calls.map((c) => c[0])).toEqual(NAMES) + expect(deps.install).not.toHaveBeenCalled() + expect(bundledStatuses()).toEqual([]) + expect(deps.lines.filter((line) => line.includes('adopted'))).toHaveLength(NAMES.length) + }) + + it('adopts nodes but skips installs when the control plane has no install API', async () => { + const deps = fakeDeps() + deps.hasInstallApi = vi.fn(async () => false) + + await ensureBundledAgents({ ...baseInput, installed: [NAMES[0]] }, deps) + + expect(deps.markProvisioned).toHaveBeenCalledWith(NAMES[0]) + expect(deps.install).not.toHaveBeenCalled() + expect(bundledStatuses()).toEqual([]) + expect(deps.lines.some((line) => line.includes('install API'))).toBe(true) + }) + + it('treats a rejecting install API check as unavailable without throwing', async () => { + const deps = fakeDeps() + deps.hasInstallApi = vi.fn(async () => { + throw new Error('probe failed') + }) + + await expect(ensureBundledAgents(baseInput, deps)).resolves.toBeUndefined() + expect(deps.install).not.toHaveBeenCalled() + expect(bundledStatuses()).toEqual([]) + }) + + it('works without the optional onInstalled hook', async () => { + const deps = fakeDeps() + const { onInstalled: _unused, ...rest } = deps + await expect(ensureBundledAgents(baseInput, rest)).resolves.toBeUndefined() + expect(deps.markProvisioned.mock.calls.map((c) => c[0])).toEqual(NAMES) + }) + + it('refuses to run twice concurrently', async () => { + const deps = fakeDeps() + const tick = () => new Promise((resolve) => setTimeout(resolve, 0)) + const pending: Array<() => void> = [] + deps.install.mockImplementation( + (name: string) => + new Promise((resolve) => { + pending.push(() => resolve({ ok: true, message: `${name} installed` })) + }) + ) + + const first = ensureBundledAgents(baseInput, deps) + await tick() + await ensureBundledAgents(baseInput, deps) + expect(deps.lines).toContain('bundled: provisioning already in progress') + expect(deps.install).toHaveBeenCalledTimes(1) + + while (pending.length > 0) { + pending.shift()!() + await tick() + } + await first + expect(deps.install).toHaveBeenCalledTimes(2) + }) + + it('resetBundledState clears rows left by a previous run', async () => { + const deps = fakeDeps({ [NAMES[0]]: { ok: false, message: 'boom' } }) + await ensureBundledAgents(baseInput, deps) + expect(bundledStatuses()).toHaveLength(1) + resetBundledState() + expect(bundledStatuses()).toEqual([]) + }) + + it('hands out copies, so a caller cannot mutate the live rows', async () => { + const deps = fakeDeps({ [NAMES[0]]: { ok: false, message: 'boom' } }) + await ensureBundledAgents(baseInput, deps) + bundledStatuses()[0].phase = 'installed' + expect(bundledStatuses()[0].phase).toBe('failed') + }) +}) diff --git a/desktop/src/main/bundledAgents.ts b/desktop/src/main/bundledAgents.ts new file mode 100644 index 000000000..cbd2c7548 --- /dev/null +++ b/desktop/src/main/bundledAgents.ts @@ -0,0 +1,237 @@ +// Provision the agent nodes that ship with the app (shared/bundled.ts) on +// first launch, so a fresh install already has swe-planner and pr-af in the +// Agents library instead of an empty view and a marketplace to shop in. +// +// Delivery is "fetch on first launch", not "baked into the installer": the app +// installs them through the same control-plane install API a user-initiated +// install uses, which is why nothing here knows about packaging. What it adds +// is the decision of when to press install, and a status row per node so the +// UI can show the two arriving before they exist on disk. +// +// Same two-part shape as aforge-companion.ts, for the same reason: +// 1. planBundledInstalls() — pure: given the registry, what we already +// provisioned, the skip env var, the resolved CLI and whether the control +// plane is up, decide which names to install. +// 2. ensureBundledAgents() — the effect, driven by injected deps so tests +// never install anything. +// +// Best-effort by construction: every failure is captured into a `failed` +// status row, nothing throws, so a dead network can't break startup. A failed +// node is deliberately NOT marked provisioned, so the next launch retries it. +// +// Deliberately does NOT import from 'electron' so it stays unit-testable. + +import { BUNDLED_NODES } from '../shared/bundled' +import type { BundledPhase, BundledStatus, InstallResult } from '../shared/types' + +/** Everything the provisioning decision depends on, observed by the caller. */ +export interface BundledPlanInput { + /** Names in ~/.agentfield/installed.yaml right now. */ + installed: readonly string[] + /** settings.provisionedBundled */ + provisioned: readonly string[] + /** AGENTFIELD_SKIP_BUNDLED — '1' disables provisioning entirely. */ + skipEnv: string | undefined + /** The resolved af command, or null when none is usable. */ + cliCommand: string | null + /** The control plane answered as a recognized AgentField. */ + controlPlaneReachable: boolean + /** Whether the active control plane is a configured cloud connection. */ + cloudActive: boolean + /** The installed-agent registry was read successfully. */ + registryReadable: boolean +} + +export interface BundledPlan { + /** Bundled node names to install, in order. */ + install: string[] + /** Already-installed bundled node names to record as provisioned. */ + adopt: string[] + /** One line for the log explaining the decision. */ + reason: string +} + +export function planBundledInstalls(input: BundledPlanInput): BundledPlan { + if (input.skipEnv === '1') { + return { install: [], adopt: [], reason: 'AGENTFIELD_SKIP_BUNDLED=1 — skipping bundled nodes' } + } + if (input.cliCommand === null || input.cliCommand.trim() === '') { + return { install: [], adopt: [], reason: 'no usable af CLI — skipping bundled nodes' } + } + if (input.cloudActive) { + return { + install: [], + adopt: [], + reason: 'cloud control plane active — bundled nodes are provisioned on the local control plane only' + } + } + // The install API lives on the control plane, so there is nothing to talk to + // until it is up and recognized. Not an error: the next launch retries. + if (!input.controlPlaneReachable) { + return { install: [], adopt: [], reason: 'control plane unavailable — skipping bundled nodes' } + } + if (!input.registryReadable) { + return { + install: [], + adopt: [], + reason: 'could not read the installed-agent registry — skipping bundled nodes' + } + } + + // Two independent reasons to leave a node alone: it is already in the + // registry (nothing to do), or we provisioned it once before and the user + // has since removed it (their choice must stick across launches). + const installed = new Set(input.installed) + const provisioned = new Set(input.provisioned) + const adopt = BUNDLED_NODES.map((entry) => entry.name).filter( + (name) => installed.has(name) && !provisioned.has(name) + ) + const install = BUNDLED_NODES.map((entry) => entry.name).filter( + (name) => !installed.has(name) && !provisioned.has(name) + ) + if (install.length === 0 && adopt.length === 0) { + return { install: [], adopt: [], reason: 'bundled nodes already provisioned' } + } + const reasons: string[] = [] + if (adopt.length > 0) reasons.push(`adopting already-installed bundled nodes: ${adopt.join(', ')}`) + if (install.length > 0) reasons.push(`provisioning bundled nodes: ${install.join(', ')}`) + return { install, adopt, reason: reasons.join('; ') } +} + +export interface BundledDeps { + /** installer.installAgent — resolves, never rejects. */ + install: (name: string, onLine: (line: string) => void) => Promise + /** Persist one name into settings.provisionedBundled. */ + markProvisioned: (name: string) => Promise + /** Whether this control plane supports installing agent packages. */ + hasInstallApi?: () => Promise + /** Called after a node installs successfully, before the next one starts. */ + onInstalled?: (name: string) => Promise + log: (message: string) => void +} + +// Live provisioning rows for the snapshot, module state because the run is a +// launch-time side effect with no owner object to hang it off — same place the +// once-per-launch latch in aforge-companion.ts lives. +// +// Lifetime rule: rows appear when a run starts and `installed` rows are dropped +// when the whole run finishes, so a node the registry now lists never keeps a +// phantom row. `failed` rows survive for the rest of the session — that is the +// only place the user is told the node did not arrive, and the retry does not +// happen until the next launch. +let statuses: BundledStatus[] = [] +let running = false + +/** Live provisioning state for the snapshot. Empty before/after a run. */ +export function bundledStatuses(): BundledStatus[] { + return statuses.map((status) => ({ ...status })) +} + +/** Test hook: clear module state between cases. */ +export function resetBundledState(): void { + statuses = [] + running = false +} + +function setPhase(name: string, phase: BundledPhase, message: string): void { + const row = statuses.find((status) => status.name === name) + if (!row) return + row.phase = phase + row.message = message +} + +/** Run the plan sequentially. Resolves when done; never rejects. */ +export async function ensureBundledAgents( + input: BundledPlanInput, + deps: BundledDeps +): Promise { + // Re-entrancy guard for the same reason the loop below is sequential: the + // control-plane install API answers a concurrent install with 409. + if (running) { + deps.log('bundled: provisioning already in progress') + return + } + try { + const plan = planBundledInstalls(input) + deps.log(`bundled: ${plan.reason}`) + if (plan.install.length === 0 && plan.adopt.length === 0) return + + running = true + for (const name of plan.adopt) { + try { + await deps.markProvisioned(name) + deps.log(`bundled: adopted ${name} (already installed)`) + } catch (err) { + deps.log(`bundled: could not record ${name} as provisioned — ${String(err)}`) + } + } + + if (plan.install.length === 0) return + + if (deps.hasInstallApi) { + let hasInstallApi = false + try { + hasInstallApi = await deps.hasInstallApi() + } catch { + // An unreadable capability endpoint is equivalent to no usable API. + } + if (!hasInstallApi) { + deps.log( + 'bundled: control plane has no install API — skipping bundled nodes (update the control plane)' + ) + return + } + } + + // Seed every planned row up front so the Agents view shows both nodes + // immediately, rather than revealing the second one minutes later. + statuses = plan.install.map((name) => { + const entry = BUNDLED_NODES.find((candidate) => candidate.name === name) + return { + name, + description: entry?.description ?? '', + language: entry?.language, + phase: 'pending' as BundledPhase, + message: '' + } + }) + + for (const name of plan.install) { + setPhase(name, 'installing', '') + let result: InstallResult + try { + result = await deps.install(name, (line) => setPhase(name, 'installing', line)) + } catch (err) { + // installAgent is documented never to reject; treat a broken dep as a + // failed install rather than letting it escape into app startup. + result = { ok: false, message: String(err) } + } + + if (!result.ok) { + setPhase(name, 'failed', result.message) + deps.log(`bundled: ${name} failed — ${result.message}`) + continue + } + + setPhase(name, 'installed', result.message) + deps.log(`bundled: ${name} installed`) + // Recorded only on success, so a failure retries on the next launch. + try { + await deps.markProvisioned(name) + } catch (err) { + deps.log(`bundled: could not record ${name} as provisioned — ${String(err)}`) + } + try { + await deps.onInstalled?.(name) + } catch (err) { + deps.log(`bundled: post-install step for ${name} failed — ${String(err)}`) + } + } + } catch (err) { + deps.log(`bundled: provisioning aborted — ${String(err)}`) + } finally { + // Drop the rows the registry now covers; keep the failures visible. + statuses = statuses.filter((status) => status.phase === 'failed') + running = false + } +} diff --git a/desktop/src/main/cloud.test.ts b/desktop/src/main/cloud.test.ts index 3b6647233..3e400fc05 100644 --- a/desktop/src/main/cloud.test.ts +++ b/desktop/src/main/cloud.test.ts @@ -157,6 +157,30 @@ describe('applyConnectionProfile', () => { expect(getApiKey()).toBeNull() }) + it('seeds the local port from the configured or last-used port', () => { + applyConnectionProfile({ ...DEFAULT_SETTINGS, controlPlanePort: 18480 }) + expect(getBaseUrl()).toBe('http://localhost:18480') + applyConnectionProfile({ ...DEFAULT_SETTINGS, lastControlPlanePort: 8090 }) + expect(getBaseUrl()).toBe('http://localhost:8090') + // A configured port beats the remembered one. + applyConnectionProfile({ + ...DEFAULT_SETTINGS, + controlPlanePort: 18480, + lastControlPlanePort: 8090 + }) + expect(getBaseUrl()).toBe('http://localhost:18480') + // Cloud still wins while enabled, and the seeded port is what a switch + // back to local returns to. + applyConnectionProfile({ + ...DEFAULT_SETTINGS, + controlPlanePort: 18480, + cloud: { enabled: true, serverUrl: 'https://cp.example', apiKey: 'k' } + }) + expect(getBaseUrl()).toBe('https://cp.example') + applyConnectionProfile({ ...DEFAULT_SETTINGS, controlPlanePort: 18480 }) + expect(getBaseUrl()).toBe('http://localhost:18480') + }) + it('carries a configured local API key on the local profile', () => { applyConnectionProfile({ ...DEFAULT_SETTINGS, localApiKey: 'local-secret' }) expect(getBaseUrl()).toBe('http://localhost:8080') diff --git a/desktop/src/main/cloud.ts b/desktop/src/main/cloud.ts index 5fc612e71..b412d2341 100644 --- a/desktop/src/main/cloud.ts +++ b/desktop/src/main/cloud.ts @@ -1,6 +1,6 @@ import type { CloudTestResult, DesktopSettings } from '../shared/types' import { connect as netConnect } from 'node:net' -import { clearCloudConnection, setCloudConnection, setLocalApiKey } from './connection' +import { clearCloudConnection, setCloudConnection, setLocalApiKey, setLocalPort } from './connection' export type { CloudTestResult } from '../shared/types' @@ -241,6 +241,12 @@ export async function testCloudConnection( } export function applyConnectionProfile(settings: DesktopSettings): void { + // The local control plane lives on the configured port, or the one the + // last launch ended up on. Seed it before anything polls: otherwise the + // first snapshots of a launch go to :8080 while autostart is still + // resolving the real port — and read whatever else answers there. + const localPort = settings.controlPlanePort ?? settings.lastControlPlanePort + if (localPort !== null && localPort !== undefined) setLocalPort(localPort) // Kept current even while cloud is active, so switching back to local // restores the local credential rather than dropping to no key at all. setLocalApiKey(settings.localApiKey ?? '') diff --git a/desktop/src/main/cpClient.ts b/desktop/src/main/cpClient.ts index e2d805b55..f1c9ac2fe 100644 --- a/desktop/src/main/cpClient.ts +++ b/desktop/src/main/cpClient.ts @@ -126,6 +126,7 @@ export type SecretScope = 'node' | 'global' export interface AgentSecretStatus { key: string is_set: boolean + env?: boolean scope?: SecretScope declared_scope?: SecretScope description?: string diff --git a/desktop/src/main/index.ts b/desktop/src/main/index.ts index bc275a066..8ada2990b 100644 --- a/desktop/src/main/index.ts +++ b/desktop/src/main/index.ts @@ -1,7 +1,8 @@ import { join, resolve } from 'node:path' import { existsSync } from 'node:fs' -import { BrowserWindow, Menu, app, ipcMain, nativeTheme, safeStorage, shell } from 'electron' +import { BrowserWindow, Menu, Notification, app, ipcMain, nativeTheme, safeStorage, shell } from 'electron' import { CATALOG } from '../shared/catalog' +import { BUNDLED_NODES } from '../shared/bundled' import { RAILWAY_TEMPLATE_URL } from '../shared/cloudLinks' import { DEEP_LINK_SCHEME, type View, deepLinkFromArgv, parseDeepLink } from '../shared/deeplink' import type { DesktopSettings } from '../shared/types' @@ -9,11 +10,14 @@ import { getBaseUrl, getSnapshot, setActiveControlPlanePort } from './agentfield import { type AgentAction, runAgentAction, startControlPlane, uninstallAgent } from './agents' import { ensureAforgeCompanion } from './aforge-companion' import { runAutostart } from './autostart' +import { bundledStatuses, ensureBundledAgents } from './bundledAgents' import { testCloudConnection, applyConnectionProfile } from './cloud' import { isCloudActive } from './connection' -import { initializeCli, installBundledCli, refreshCliStatus } from './cli' +import { createCpClient } from './cpClient' +import { getCliCommand, initializeCli, installBundledCli, refreshCliStatus } from './cli' import { initUserPath } from './env' import { installAgent, installFromSource, updateAgent } from './installer' +import { notifyUnresolvedKeys } from './keyNotice' import { getEnvReports, listStoredSecrets, @@ -197,7 +201,43 @@ function registerDeepLinks(): void { } } +/** + * The one install mutex for the whole app: the IPC handlers below and + * first-launch bundled provisioning both go through it, because the control + * plane answers a second concurrent install with a 409. + * + * The two callers want different things when it is taken. A handler is + * answering a click, so it refuses straight away and the renderer says so. + * Provisioning is background work nobody is watching, so it waits its turn + * instead of failing a bundled node over a coincidence of timing. + */ let installInFlight = false +/** Provisioning calls parked until the current install finishes, in order. */ +const installWaiters: Array<() => void> = [] + +/** Take the mutex, waiting when it is held. Used by provisioning only. */ +function acquireInstall(): Promise { + if (!installInFlight) { + installInFlight = true + return Promise.resolve() + } + return new Promise((resolve) => installWaiters.push(resolve)) +} + +/** + * Release the mutex. A parked waiter is handed the lock directly — the flag + * stays set across the handoff, so a handler can never slip in during the + * microtask it takes the waiter to resume. + */ +function releaseInstall(): void { + const next = installWaiters.shift() + if (next) { + next() + return + } + installInFlight = false +} + let cloudDeployInFlight = false let settings: DesktopSettings @@ -291,6 +331,107 @@ function syncSkills(reason: string): void { }) } +/** + * Install the nodes that ship with the app (see shared/bundled.ts) on the + * first launch that can reach a control plane. They are not marketplace rows: + * the app fetches them through the same install API, then shows them in the + * Agents view, so a brand-new user has a working software factory without + * choosing anything. + * + * Called from the boot chain AFTER runAutostart resolves — installing needs a + * live control plane, and autostart is what adopts or starts one. Best-effort + * throughout: ensureBundledAgents never rejects, and a node that fails is left + * unrecorded so the next launch retries it. + */ +async function provisionBundledAgents(): Promise { + // One snapshot answers both questions the plan needs: which nodes are + // already installed, and whether the control plane we just booted actually + // answered as an AgentField. It is read here rather than before autostart + // because the active port may have moved (adopted, or freshly picked). + const snapshot = await getSnapshot() + // What this run actually installed, collected from onInstalled below — + // ensureBundledAgents plans internally and reports nothing back, and the + // key notice must speak only for the nodes that just arrived. + const justProvisioned: string[] = [] + await ensureBundledAgents( + { + installed: snapshot.registry.agents.map((agent) => agent.name), + provisioned: settings.provisionedBundled, + skipEnv: process.env.AGENTFIELD_SKIP_BUNDLED, + cliCommand: getCliCommand(), + cloudActive: isCloudActive(), + // recognized, not reachable: an unrelated service holding the port + // would answer, and installing through it would fail every time. + controlPlaneReachable: snapshot.controlPlane.recognized, + registryReadable: snapshot.registry.exists && !snapshot.registry.error + }, + { + // Shares the app-wide install mutex, waiting when the user started an + // install of their own — see acquireInstall/releaseInstall. + install: async (name, onLine) => { + await acquireInstall() + try { + return await installAgent(name, onLine) + } finally { + releaseInstall() + } + }, + // Remember the node was provisioned so it is never auto-installed + // again: uninstalling a bundled node has to stick across launches. + markProvisioned: async (name) => { + settings = mergeSettings(settings, { + provisionedBundled: [...settings.provisionedBundled, name] + }) + await saveSettings(settingsFile(), settings) + }, + hasInstallApi: () => createCpClient().hasInstallApi(), + // Start it from the NEXT launch on, not now. Both bundled nodes need an + // API key the first-launch user has not entered yet, so starting one + // here would only produce a dead node and an alarming badge; the Agents + // row's "Needs keys" chip is the affordance that actually helps. + onInstalled: async (name) => { + justProvisioned.push(name) + settings = mergeSettings(settings, { + autostartAgents: [...settings.autostartAgents, name] + }) + await saveSettings(settingsFile(), settings) + }, + // bundledAgents.ts already prefixes every line with "bundled: " — the + // module owns its own log voice, the way autostart.ts does. + log: (message) => console.log(message) + } + ) + + // Nothing was started above, on purpose — both bundled nodes need an API key + // the first-launch user has not entered. On a login-item launch the app is + // hidden in the tray, so the Agents row's "Needs keys" chip is telling an + // empty room. One OS notification is the only thing that reaches the user + // here. keyNotice.ts decides; this is just the Electron effect. + await notifyUnresolvedKeys(justProvisioned, settings.keyNoticeShown, { + // The one authoritative source: composed from the control plane's + // per-agent secrets endpoint, i.e. the encrypted store `af run` reads. + reports: () => getEnvReports(), + supported: () => Notification.isSupported(), + show: ({ title, body }) => { + const notice = new Notification({ title, body }) + // The notice names keys; the Keys editor lives on the Agents rows, so + // that is where a click has to land. navigate() also un-hides the + // window, which is the whole point on a tray-only launch. + notice.on('click', () => navigate('agents')) + notice.show() + }, + // The at-most-once latch: an announced name is persisted, and keyNotice.ts + // filters on it, so no launch can raise the same notice twice. + markNotified: async (agents) => { + settings = mergeSettings(settings, { + keyNoticeShown: [...settings.keyNoticeShown, ...agents] + }) + await saveSettings(settingsFile(), settings) + }, + log: (message) => console.log(message) + }) +} + // Register (or clear) the OS login item. Dev builds skip it — registering // electron.exe as a login item would be wrong and confusing. function applyLoginItem(next: DesktopSettings): void { @@ -370,8 +511,14 @@ function main(): void { // The snapshot carries the last skill-sync result along with the control- // plane view, so the renderer's existing 5s poll keeps the dashboard's // skill state honest without a channel (or a loop) of its own. - ipcMain.handle('agentfield:snapshot', () => getSnapshot({ skillSync: skillSync.last() })) - ipcMain.handle('agentfield:catalog', () => CATALOG) + // The snapshot also carries the bundled-node provisioning rows, so the + // Agents view can show the two nodes arriving on a first launch off the + // poll it already runs. + ipcMain.handle('agentfield:snapshot', () => + getSnapshot({ skillSync: skillSync.last(), bundled: bundledStatuses() }) + ) + // Bundled nodes stay listed so an uninstalled one can be reinstalled from the curated UI. + ipcMain.handle('agentfield:catalog', () => [...BUNDLED_NODES, ...CATALOG]) ipcMain.handle('agentfield:install', async (event, name: unknown) => { if (typeof name !== 'string') { return { ok: false, message: 'invalid install request' } @@ -387,7 +534,7 @@ function main(): void { } }) } finally { - installInFlight = false + releaseInstall() } }) // Install from a pasted GitHub repo URL. Shares the SAME install mutex and @@ -410,7 +557,7 @@ function main(): void { } }) } finally { - installInFlight = false + releaseInstall() } }) ipcMain.handle('agentfield:uninstall', (_event, name: unknown) => { @@ -436,7 +583,7 @@ function main(): void { } }) } finally { - installInFlight = false + releaseInstall() } }) ipcMain.handle('agentfield:agent-action', (_event, action: unknown, name: unknown) => { @@ -667,16 +814,23 @@ function main(): void { // The port autostart ends up on (adopted or freshly picked) is persisted // so the next app start finds this control plane again instead of // spawning a second one somewhere else. - void userPathReady.finally(() => - runAutostart( - settings, - (message) => console.log(message), - async (port) => { - settings = mergeSettings(settings, { lastControlPlanePort: port }) - await saveSettings(settingsFile(), settings) - } - ).catch((err) => console.error('autostart failed:', err)) - ) + // + // Bundled-node provisioning is chained onto the SAME promise rather than + // started beside it: it installs through the control plane, so it has to + // wait for the one autostart adopted or brought up. + void userPathReady + .finally(() => + runAutostart( + settings, + (message) => console.log(message), + async (port) => { + settings = mergeSettings(settings, { lastControlPlanePort: port }) + await saveSettings(settingsFile(), settings) + } + ).catch((err) => console.error('autostart failed:', err)) + ) + .then(() => provisionBundledAgents()) + .catch((err) => console.error('bundled provisioning failed:', err)) app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow() diff --git a/desktop/src/main/keyNotice.test.ts b/desktop/src/main/keyNotice.test.ts new file mode 100644 index 000000000..3b7355364 --- /dev/null +++ b/desktop/src/main/keyNotice.test.ts @@ -0,0 +1,303 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AgentEnvReport, AgentEnvVar } from '../shared/types' +import { + type KeyNoticeDeps, + keyNoticeCandidates, + missingKeyLabels, + notifyUnresolvedKeys, + planKeyNotice +} from './keyNotice' + +function variable(partial: Partial & { name: string }): AgentEnvVar { + return { + description: '', + secret: true, + scope: 'global', + required: true, + status: 'missing', + storedScopes: [], + ...partial + } +} + +/** A report shaped like getEnvReports() builds one, satisfied derived by hand. */ +function report(agent: string, vars: AgentEnvVar[], satisfied: boolean): AgentEnvReport { + return { agent, vars, satisfied } +} + +const NEEDS_OPENROUTER = report( + 'swe-planner', + [variable({ name: 'OPENROUTER_API_KEY' })], + false +) +const NEEDS_TOKEN = report('pr-af', [variable({ name: 'GH_TOKEN' })], false) + +describe('keyNoticeCandidates', () => { + it('drops names already announced and dedupes the rest', () => { + expect(keyNoticeCandidates(['a', 'b', 'a'], ['b'])).toEqual(['a']) + expect(keyNoticeCandidates(['a'], ['a'])).toEqual([]) + expect(keyNoticeCandidates([], [])).toEqual([]) + expect(keyNoticeCandidates(['', 'a'], [])).toEqual(['a']) + }) +}) + +describe('missingKeyLabels', () => { + it('names each unresolved required variable', () => { + expect(missingKeyLabels(NEEDS_OPENROUTER)).toEqual(['OPENROUTER_API_KEY']) + }) + + it('ignores optional and already-resolved variables', () => { + const r = report( + 'x', + [ + variable({ name: 'SET_IN_ENV', status: 'env' }), + variable({ name: 'IN_STORE', status: 'stored' }), + variable({ name: 'HAS_DEFAULT', status: 'default' }), + variable({ name: 'OPTIONAL_ONE', required: false, status: 'missing' }), + variable({ name: 'REALLY_MISSING' }) + ], + false + ) + expect(missingKeyLabels(r)).toEqual(['REALLY_MISSING']) + }) + + it('collapses a require_one_of group into one "A or B" label', () => { + const r = report( + 'x', + [ + variable({ name: 'ANTHROPIC_API_KEY', group: 'llm' }), + variable({ name: 'OPENROUTER_API_KEY', group: 'llm' }) + ], + false + ) + expect(missingKeyLabels(r)).toEqual(['ANTHROPIC_API_KEY or OPENROUTER_API_KEY']) + }) + + it('says nothing about a group one member already satisfies', () => { + const r = report( + 'x', + [ + variable({ name: 'ANTHROPIC_API_KEY', group: 'llm', status: 'stored' }), + variable({ name: 'OPENROUTER_API_KEY', group: 'llm' }), + variable({ name: 'GH_TOKEN' }) + ], + false + ) + expect(missingKeyLabels(r)).toEqual(['GH_TOKEN']) + }) +}) + +describe('planKeyNotice', () => { + const base = { + provisioned: ['swe-planner', 'pr-af'], + reports: [NEEDS_OPENROUTER, NEEDS_TOKEN], + alreadyNotified: [] as string[], + supported: true + } + + it('names every unresolved agent and what it needs', () => { + const plan = planKeyNotice(base) + expect(plan.notify).toBe(true) + expect(plan.agents).toEqual(['swe-planner', 'pr-af']) + expect(plan.title).toBe('2 agents need keys') + expect(plan.body).toBe( + 'swe-planner needs OPENROUTER_API_KEY; pr-af needs GH_TOKEN — click to add them in AgentField → Agents → Keys.' + ) + }) + + it('uses singular copy for a single agent with a single key', () => { + const plan = planKeyNotice({ ...base, provisioned: ['swe-planner'] }) + expect(plan.title).toBe('swe-planner needs a key') + expect(plan.body).toBe( + 'swe-planner needs OPENROUTER_API_KEY — click to add it in AgentField → Agents → Keys.' + ) + }) + + it('never names a secret value, only variable names', () => { + const r = report('x', [variable({ name: 'GH_TOKEN' })], false) + const plan = planKeyNotice({ ...base, provisioned: ['x'], reports: [r] }) + expect(plan.body).toContain('GH_TOKEN') + expect(plan.body).not.toMatch(/ghp_|sk-/) + }) + + it('stays silent when every provisioned agent is satisfied', () => { + const plan = planKeyNotice({ + ...base, + reports: [ + report('swe-planner', [variable({ name: 'OPENROUTER_API_KEY', status: 'stored' })], true), + report('pr-af', [variable({ name: 'GH_TOKEN', status: 'stored' })], true) + ] + }) + expect(plan.notify).toBe(false) + expect(plan.reason).toContain('every required key') + }) + + it('trusts satisfied over the variable statuses (old control planes report satisfied: true)', () => { + // secrets.ts falls back to satisfied: true when the control plane cannot + // report `requirement` metadata — every var then looks required+missing. + // Notifying there would be a guess, so the fallback must win. + const legacy = report( + 'swe-planner', + [variable({ name: 'OPENROUTER_API_KEY', status: 'missing' })], + true + ) + const plan = planKeyNotice({ ...base, provisioned: ['swe-planner'], reports: [legacy] }) + expect(plan.notify).toBe(false) + }) + + it('stays silent on the control-plane error report', () => { + const err: AgentEnvReport = { + agent: '', + vars: [], + satisfied: false, + error: 'Could not reach the control plane' + } + const plan = planKeyNotice({ ...base, reports: [err] }) + expect(plan.notify).toBe(false) + }) + + it('stays silent when an agent has no report at all', () => { + expect(planKeyNotice({ ...base, reports: [] }).notify).toBe(false) + }) + + it('stays silent when unsatisfied but nothing is nameable', () => { + const odd = report('swe-planner', [variable({ name: 'X', required: false })], false) + const plan = planKeyNotice({ ...base, provisioned: ['swe-planner'], reports: [odd] }) + expect(plan.notify).toBe(false) + }) + + it('skips agents already announced on an earlier launch', () => { + const plan = planKeyNotice({ ...base, alreadyNotified: ['swe-planner'] }) + expect(plan.agents).toEqual(['pr-af']) + expect(plan.title).toBe('pr-af needs a key') + }) + + it('does not notify when nothing was provisioned this run', () => { + const plan = planKeyNotice({ ...base, provisioned: [] }) + expect(plan.notify).toBe(false) + expect(plan.reason).toContain('nothing newly provisioned') + }) + + it('does not notify when notifications are unsupported', () => { + const plan = planKeyNotice({ ...base, supported: false }) + expect(plan.notify).toBe(false) + expect(plan.agents).toEqual([]) + }) + + it('elides long lists but still records every agent', () => { + const many = ['a', 'b', 'c', 'd'] + const plan = planKeyNotice({ + ...base, + provisioned: many, + reports: many.map((name) => + report( + name, + ['K1', 'K2', 'K3', 'K4'].map((key) => variable({ name: `${name}_${key}` })), + false + ) + ) + }) + expect(plan.title).toBe('4 agents need keys') + expect(plan.body).toContain('a needs a_K1, a_K2, a_K3 and 1 more') + expect(plan.body).toContain('(and 1 more agent)') + expect(plan.body).not.toContain('d needs') + // Elided agents are still recorded — the notice counted them. + expect(plan.agents).toEqual(many) + }) +}) + +function deps(overrides: Partial = {}): KeyNoticeDeps & { + shown: { title: string; body: string }[] + recorded: string[][] + logs: string[] +} { + const shown: { title: string; body: string }[] = [] + const recorded: string[][] = [] + const logs: string[] = [] + return { + shown, + recorded, + logs, + reports: async () => [NEEDS_OPENROUTER, NEEDS_TOKEN], + supported: () => true, + show: (notice) => { + shown.push(notice) + }, + markNotified: async (agents) => { + recorded.push([...agents]) + }, + log: (message) => { + logs.push(message) + }, + ...overrides + } +} + +describe('notifyUnresolvedKeys', () => { + it('shows one notification and records the agents', async () => { + const d = deps() + const plan = await notifyUnresolvedKeys(['swe-planner', 'pr-af'], [], d) + expect(plan.notify).toBe(true) + expect(d.shown).toHaveLength(1) + expect(d.shown[0].title).toBe('2 agents need keys') + expect(d.recorded).toEqual([['swe-planner', 'pr-af']]) + }) + + it('does not fire again once the agents are recorded', async () => { + const d = deps() + await notifyUnresolvedKeys(['swe-planner', 'pr-af'], ['swe-planner', 'pr-af'], d) + expect(d.shown).toEqual([]) + expect(d.recorded).toEqual([]) + }) + + it('skips the control-plane round trip when there is nothing to announce', async () => { + const reports = vi.fn(async () => [NEEDS_OPENROUTER]) + const d = deps({ reports }) + await notifyUnresolvedKeys([], [], d) + expect(reports).not.toHaveBeenCalled() + }) + + it('skips the round trip and shows nothing when unsupported', async () => { + const reports = vi.fn(async () => [NEEDS_OPENROUTER]) + const d = deps({ supported: () => false, reports }) + await notifyUnresolvedKeys(['swe-planner'], [], d) + expect(reports).not.toHaveBeenCalled() + expect(d.shown).toEqual([]) + expect(d.recorded).toEqual([]) + }) + + it('records nothing when the notification itself fails', async () => { + const d = deps({ + show: () => { + throw new Error('no notification daemon') + } + }) + const plan = await notifyUnresolvedKeys(['swe-planner'], [], d) + expect(plan.notify).toBe(false) + expect(d.recorded).toEqual([]) + }) + + it('keeps the notice when persisting it fails', async () => { + const d = deps({ + markNotified: async () => { + throw new Error('disk full') + } + }) + const plan = await notifyUnresolvedKeys(['swe-planner'], [], d) + expect(plan.notify).toBe(true) + expect(d.shown).toHaveLength(1) + expect(d.logs.some((line) => line.includes('could not record'))).toBe(true) + }) + + it('never rejects when a dependency throws', async () => { + const d = deps({ + reports: async () => { + throw new Error('boom') + } + }) + const plan = await notifyUnresolvedKeys(['swe-planner'], [], d) + expect(plan.notify).toBe(false) + expect(plan.reason).toContain('aborted') + expect(d.shown).toEqual([]) + }) +}) diff --git a/desktop/src/main/keyNotice.ts b/desktop/src/main/keyNotice.ts new file mode 100644 index 000000000..3b0c9f03f --- /dev/null +++ b/desktop/src/main/keyNotice.ts @@ -0,0 +1,233 @@ +// Tell the user, once, when first-launch provisioning finished on agents that +// still cannot run for want of an API key. +// +// The app is designed to open at login, hidden, in the tray (applyLoginItem in +// index.ts). On such a launch bundledAgents.ts installs swe-planner and pr-af, +// deliberately does NOT start them — both need a key the user has not entered — +// and puts a "Needs keys" chip on their Agents rows. Nobody is looking at that +// window. Without a push the two nodes sit there unusable and the user finds +// out later, from a coding agent that could not call them. One native +// notification closes that loop: name the keys, name where to enter them. +// +// Same two-part shape as aforge-companion.ts and bundledAgents.ts: +// 1. planKeyNotice() — pure: given the names this run provisioned, the env +// reports and what was already announced, decide whether to notify and +// what the copy says. +// 2. notifyUnresolvedKeys() — the effect, driven by injected deps so tests +// never construct an Electron Notification. +// +// Best-effort by construction: nothing here throws, every failure resolves to +// a "did not notify" plan, so a missing notification daemon can never delay or +// break startup. +// +// Deliberately does NOT import from 'electron' so it stays unit-testable — the +// Notification call is the caller's ten lines in index.ts, the same split +// tray-model.ts / tray.ts use. + +import type { AgentEnvReport } from '../shared/types' + +/** Keys named per agent before the copy elides the rest. */ +const MAX_LABELS_PER_AGENT = 3 +/** Agents named before the copy elides the rest. */ +const MAX_AGENTS = 3 + +export interface KeyNoticeInput { + /** + * Bundled node names THIS launch's provisioning run installed. Deliberately + * not "every provisioned node": the notice belongs to the provisioning + * event, so a launch that installs nothing never re-raises it. The cost is + * that a notice lost to a control-plane hiccup is not retried — acceptable, + * because the Agents row's "Needs keys" chip is the standing affordance and + * this is only the push that points at it. + */ + provisioned: readonly string[] + /** getEnvReports() — see the authority note on `satisfied` below. */ + reports: readonly AgentEnvReport[] + /** settings.keyNoticeShown — agents already announced on an earlier launch. */ + alreadyNotified: readonly string[] + /** Notification.isSupported() — false on a desktop with no notification daemon. */ + supported: boolean +} + +export interface KeyNoticePlan { + notify: boolean + /** + * The agents this notice speaks for. Recorded in settings on delivery, so + * they are never announced again. + */ + agents: string[] + title: string + body: string + /** One line for the log explaining the decision. */ + reason: string +} + +const SILENT: Omit = { notify: false, agents: [], title: '', body: '' } + +/** + * Which of the just-provisioned names have not been announced yet. Exported so + * the runner can skip the control-plane round trip when the answer is "none" + * without duplicating the rule. + */ +export function keyNoticeCandidates( + provisioned: readonly string[], + alreadyNotified: readonly string[] +): string[] { + const seen = new Set(alreadyNotified) + // Dedupe as well as filter: the caller collects names from a per-install + // callback, and settings.keyNoticeShown must never gain a duplicate. + return [...new Set(provisioned)].filter((name) => name !== '' && !seen.has(name)) +} + +/** + * The unresolved required keys of one agent, as user-facing labels. + * + * A `require_one_of` group yields ONE label listing its alternatives — e.g. + * "ANTHROPIC_API_KEY or OPENROUTER_API_KEY" — and only when every alternative + * is missing: telling someone who already stored an Anthropic key that they + * need an OpenRouter key would be false. + */ +export function missingKeyLabels(report: AgentEnvReport): string[] { + const resolvedGroups = new Set( + report.vars + .filter((variable) => variable.group && variable.status !== 'missing') + .map((variable) => variable.group as string) + ) + // Insertion-ordered so the copy follows the manifest's own order. Grouped + // variables share a slot keyed by group id; ungrouped ones get a private key + // that cannot collide with a group id. + const slots = new Map() + for (const variable of report.vars) { + if (!variable.required || variable.status !== 'missing') continue + if (variable.group && resolvedGroups.has(variable.group)) continue + const key = variable.group ? `group:${variable.group}` : `var:${variable.name}` + const slot = slots.get(key) + if (slot) slot.push(variable.name) + else slots.set(key, [variable.name]) + } + return [...slots.values()].map((names) => names.join(' or ')) +} + +/** "A", "A and B", "A, B and C" — with an elision past MAX_LABELS_PER_AGENT. */ +function joinLabels(labels: readonly string[]): string { + const shown = labels.slice(0, MAX_LABELS_PER_AGENT) + const hidden = labels.length - shown.length + const parts = hidden > 0 ? [...shown, `${hidden} more`] : shown + if (parts.length === 1) return parts[0] + return `${parts.slice(0, -1).join(', ')} and ${parts[parts.length - 1]}` +} + +export function planKeyNotice(input: KeyNoticeInput): KeyNoticePlan { + // Degrade silently rather than half-way: no daemon, no notice, and nothing + // recorded, so a user who later gets one is still told. + if (!input.supported) { + return { ...SILENT, reason: 'native notifications unsupported — not notifying' } + } + + const candidates = keyNoticeCandidates(input.provisioned, input.alreadyNotified) + if (candidates.length === 0) { + return { ...SILENT, reason: 'nothing newly provisioned to announce' } + } + + const unresolved: { agent: string; labels: string[] }[] = [] + for (const agent of candidates) { + const report = input.reports.find((candidate) => candidate.agent === agent) + // No report at all, or the error-shaped report getEnvReports() returns when + // the control plane is unreachable ({ agent: '', vars: [], satisfied: false }). + // Silence beats guessing: we would be inventing a list of missing keys. + if (!report || report.error || report.vars.length === 0) continue + // `satisfied` is the ONLY authority here. It is composed from + // GET /api/ui/v1/agents/:id/secrets?include=env, which reads the same + // encrypted store `af run` decrypts — unlike `af doctor` or the package + // .env file, which are store-blind and call correctly stored keys unset. + // It is also deliberately `true` when the control plane is too old to + // report `requirement` metadata (secrets.ts), so an old server can never + // trigger a notice built on a guess. + if (report.satisfied) continue + const labels = missingKeyLabels(report) + // Unsatisfied but nothing nameable: a shape we do not understand. The copy + // has to say what is missing, so say nothing instead. + if (labels.length === 0) continue + unresolved.push({ agent, labels }) + } + + if (unresolved.length === 0) { + return { ...SILENT, reason: 'newly provisioned agents have every required key' } + } + + const shown = unresolved.slice(0, MAX_AGENTS) + const hidden = unresolved.length - shown.length + const overflow = hidden > 0 ? ` (and ${hidden} more agent${hidden === 1 ? '' : 's'})` : '' + const detail = shown.map((row) => `${row.agent} needs ${joinLabels(row.labels)}`).join('; ') + const oneKey = unresolved.length === 1 && unresolved[0].labels.length === 1 + const title = + unresolved.length === 1 + ? `${unresolved[0].agent} needs ${oneKey ? 'a key' : 'keys'}` + : `${unresolved.length} agents need keys` + + return { + notify: true, + // Every unresolved agent is recorded, including the ones the copy elided: + // they were counted in the notice, and re-announcing them later would be + // the nagging this module exists to avoid. + agents: unresolved.map((row) => row.agent), + title, + body: `${detail}${overflow} — click to add ${oneKey ? 'it' : 'them'} in AgentField → Agents → Keys.`, + reason: `missing keys for ${unresolved.map((row) => row.agent).join(', ')}` + } +} + +export interface KeyNoticeDeps { + /** secrets.getEnvReports — resolves to an error-shaped report, never rejects. */ + reports: () => Promise + /** Notification.isSupported() */ + supported: () => boolean + /** Show the notification; clicking it must open the app on the Agents view. */ + show: (notice: { title: string; body: string }) => void + /** Persist the announced names into settings.keyNoticeShown. */ + markNotified: (agents: readonly string[]) => Promise + log: (message: string) => void +} + +/** + * Announce unresolved keys for the nodes a provisioning run just installed. + * Resolves to the plan it acted on (tests assert on it); never rejects. + */ +export async function notifyUnresolvedKeys( + provisioned: readonly string[], + alreadyNotified: readonly string[], + deps: KeyNoticeDeps +): Promise { + try { + // Two cheap gates before the control-plane round trip: an unsupported + // platform and a run that provisioned nothing new are the common cases, + // and neither is worth an HTTP call per launch. + const supported = deps.supported() + const skip = + !supported || keyNoticeCandidates(provisioned, alreadyNotified).length === 0 + const reports = skip ? [] : await deps.reports() + + const plan = planKeyNotice({ provisioned, reports, alreadyNotified, supported }) + deps.log(`key notice: ${plan.reason}`) + if (!plan.notify) return plan + + // Show first, record second: a settings write that fails must not swallow + // a notification the user is already looking at. + try { + deps.show({ title: plan.title, body: plan.body }) + } catch (err) { + deps.log(`key notice: could not show the notification — ${String(err)}`) + return { ...plan, notify: false } + } + try { + await deps.markNotified(plan.agents) + } catch (err) { + deps.log(`key notice: could not record the notice as shown — ${String(err)}`) + } + return plan + } catch (err) { + const reason = `key notice aborted — ${String(err)}` + deps.log(`key notice: ${reason}`) + return { ...SILENT, reason } + } +} diff --git a/desktop/src/main/secrets.test.ts b/desktop/src/main/secrets.test.ts index 3d001f4c9..1270de59c 100644 --- a/desktop/src/main/secrets.test.ts +++ b/desktop/src/main/secrets.test.ts @@ -323,6 +323,32 @@ describe('control-plane secret management', () => { expect((await getEnvReports({ cpClient }))[0].satisfied).toBe(true) }) + it('distinguishes environment-only values from stored values', async () => { + const cpClient = client() + vi.mocked(cpClient.listAgentSecrets).mockResolvedValue({ + secrets: [ + { + key: 'ENV_ONLY', is_set: true, env: true, declared_scope: 'global', + requirement: 'required' + }, + { + key: 'ENV_AND_STORED', is_set: true, env: true, scope: 'global', + declared_scope: 'global', requirement: 'required' + } + ] + }) + + const [report] = await getEnvReports({ cpClient }) + + expect(report.satisfied).toBe(true) + expect(report.vars).toEqual([ + expect.objectContaining({ name: 'ENV_ONLY', status: 'env', storedScopes: [] }), + expect.objectContaining({ + name: 'ENV_AND_STORED', status: 'stored', storedScopes: ['global'] + }) + ]) + }) + it('sets and deletes without sending a scope', async () => { const cpClient = client() await setAgentSecret('agent-id', 'SET_KEY', 'value', { cpClient }) diff --git a/desktop/src/main/secrets.ts b/desktop/src/main/secrets.ts index e476de41a..119d80d24 100644 --- a/desktop/src/main/secrets.ts +++ b/desktop/src/main/secrets.ts @@ -199,6 +199,17 @@ export async function getEnvReports( const { secrets } = await deps.cpClient.listAgentSecrets(pkg.id) const hasEnvMetadata = secrets.some((secret) => Boolean(secret.requirement)) const vars: AgentEnvVar[] = secrets.map((secret) => { + // `scope` names where a STORED value lives (node/global). A control + // plane that predates the `scope` field reports is_set without one, so + // a stored value with no scope still counts as global — but a value + // the control plane's own process environment supplies (`env`, never + // stored) has no scope to delete from. + const storedScopes = secret.scope + ? [secret.scope === 'node' ? pkg.name : secret.scope] + : secret.is_set && !secret.env + ? [GLOBAL_SCOPE] + : [] + const envOnly = Boolean(secret.env) && storedScopes.length === 0 if (!hasEnvMetadata) { return { name: secret.key, @@ -206,18 +217,19 @@ export async function getEnvReports( secret: true, scope: secret.scope ?? GLOBAL_SCOPE, required: true, - status: secret.is_set ? 'stored' : 'missing', - storedScopes: secret.is_set - ? [secret.scope === 'node' ? pkg.name : secret.scope ?? GLOBAL_SCOPE] - : [] + status: envOnly ? 'env' : secret.is_set ? 'stored' : 'missing', + storedScopes } } - const status: EnvVarStatus = secret.is_set - ? 'stored' - : secret.default - ? 'default' - : 'missing' + const status: EnvVarStatus = envOnly + ? 'env' + : secret.is_set + ? 'stored' + : secret.default + ? 'default' + : 'missing' + return { name: secret.key, description: secret.description ?? '', @@ -231,9 +243,7 @@ export async function getEnvReports( groupDescription: secret.requirement === 'one_of' ? secret.group_description || undefined : undefined, status, - storedScopes: secret.is_set - ? [secret.scope === 'node' ? pkg.name : secret.scope ?? GLOBAL_SCOPE] - : [] + storedScopes } }) const groups = new Set(vars.flatMap((variable) => variable.group ? [variable.group] : [])) diff --git a/desktop/src/main/settings.test.ts b/desktop/src/main/settings.test.ts index 1614fc933..5ec2964c3 100644 --- a/desktop/src/main/settings.test.ts +++ b/desktop/src/main/settings.test.ts @@ -18,11 +18,13 @@ describe('normalizeSettings', () => { localApiKey: 'local-secret', lastControlPlanePort: 8081, autostartAgents: ['a', 'b'], + provisionedBundled: ['swe-planner'], installSkills: false, trayCompanion: false, dismissedUpdateVersion: '0.1.110', starPrompt: 'done' as const, - starPromptSnoozedUntil: '2026-08-01T00:00:00.000Z' + starPromptSnoozedUntil: '2026-08-01T00:00:00.000Z', + keyNoticeShown: ['swe-planner'] } expect(normalizeSettings(s)).toEqual(s) }) @@ -86,6 +88,18 @@ describe('normalizeSettings', () => { ).toEqual(['a', 'b']) }) + // provisionedBundled is what makes uninstalling a bundled node stick, so a + // hand-edited or corrupt list must degrade to "provision it again", never to + // a shape that could suppress or duplicate first-launch provisioning. + it('coerces provisionedBundled like autostartAgents', () => { + expect(normalizeSettings({}).provisionedBundled).toEqual([]) + expect( + normalizeSettings({ provisionedBundled: ['pr-af', 7, 'pr-af', null, 'swe-planner'] }) + .provisionedBundled + ).toEqual(['pr-af', 'swe-planner']) + expect(normalizeSettings({ provisionedBundled: 'pr-af' }).provisionedBundled).toEqual([]) + }) + it('coerces a bad dismissed update version to null', () => { expect(normalizeSettings({ dismissedUpdateVersion: 42 }).dismissedUpdateVersion).toBeNull() expect(normalizeSettings({ dismissedUpdateVersion: '' }).dismissedUpdateVersion).toBeNull() @@ -125,6 +139,13 @@ describe('mergeSettings', () => { expect(merged.openAtLogin).toBe(false) }) + it('sanitizes a provisionedBundled patch', () => { + const merged = mergeSettings(DEFAULT_SETTINGS, { + provisionedBundled: ['pr-af', { evil: true }, 'pr-af'] + }) + expect(merged.provisionedBundled).toEqual(['pr-af']) + }) + it('merges star prompt patches', () => { const done = mergeSettings(DEFAULT_SETTINGS, { starPrompt: 'done' }) expect(done.starPrompt).toBe('done') @@ -148,11 +169,13 @@ describe('load/save round trip', () => { localApiKey: 'round-trip-local-key', lastControlPlanePort: 9091, autostartAgents: ['swe-planner'], + provisionedBundled: ['swe-planner', 'pr-af'], installSkills: true, trayCompanion: true, dismissedUpdateVersion: null, starPrompt: 'pending' as const, - starPromptSnoozedUntil: null + starPromptSnoozedUntil: null, + keyNoticeShown: [] } await saveSettings(file, s) expect(await loadSettings(file)).toEqual(s) diff --git a/desktop/src/main/settings.ts b/desktop/src/main/settings.ts index 2212af554..20b44c8e1 100644 --- a/desktop/src/main/settings.ts +++ b/desktop/src/main/settings.ts @@ -15,11 +15,13 @@ export const DEFAULT_SETTINGS: DesktopSettings = { localApiKey: '', lastControlPlanePort: null, autostartAgents: [], + provisionedBundled: [], installSkills: true, trayCompanion: true, dismissedUpdateVersion: null, starPrompt: 'pending', - starPromptSnoozedUntil: null + starPromptSnoozedUntil: null, + keyNoticeShown: [] } /** A usable TCP port, or null for anything else (auto mode / not recorded). */ @@ -43,6 +45,16 @@ export function normalizeSettings(raw: unknown): DesktopSettings { const agents = Array.isArray(obj.autostartAgents) ? [...new Set(obj.autostartAgents.filter((n): n is string => typeof n === 'string'))] : DEFAULT_SETTINGS.autostartAgents + // Same coercion as autostartAgents: a hand-edited or corrupt list must not + // be able to suppress (or duplicate) first-launch provisioning. + const provisionedBundled = Array.isArray(obj.provisionedBundled) + ? [...new Set(obj.provisionedBundled.filter((n): n is string => typeof n === 'string'))] + : DEFAULT_SETTINGS.provisionedBundled + // Same again for the once-only key notice: a corrupt list must neither + // suppress the notification forever nor grow duplicates. + const keyNoticeShown = Array.isArray(obj.keyNoticeShown) + ? [...new Set(obj.keyNoticeShown.filter((n): n is string => typeof n === 'string'))] + : DEFAULT_SETTINGS.keyNoticeShown return { cloud: { enabled: Boolean(cloud.enabled), @@ -63,6 +75,7 @@ export function normalizeSettings(raw: unknown): DesktopSettings { localApiKey: typeof obj.localApiKey === 'string' ? obj.localApiKey.trim() : '', lastControlPlanePort: normalizePort(obj.lastControlPlanePort), autostartAgents: agents, + provisionedBundled, installSkills: typeof obj.installSkills === 'boolean' ? obj.installSkills : DEFAULT_SETTINGS.installSkills, trayCompanion: @@ -77,7 +90,8 @@ export function normalizeSettings(raw: unknown): DesktopSettings { obj.starPromptSnoozedUntil !== '' && Number.isFinite(Date.parse(obj.starPromptSnoozedUntil)) ? obj.starPromptSnoozedUntil - : null + : null, + keyNoticeShown } } diff --git a/desktop/src/renderer/src/App.test.ts b/desktop/src/renderer/src/App.test.ts new file mode 100644 index 000000000..1762f4c1e --- /dev/null +++ b/desktop/src/renderer/src/App.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest' +import { canDecideDefaultRoute, controlPlaneStatus, defaultView, shouldRerouteToBundled } from './App' +import type { AgentFieldSnapshot } from '../../shared/types' + +describe('defaultView', () => { + it('lands on the Agents library while bundled nodes are provisioning', () => { + // First launch: the two bundled rows are the content, so add-mode would + // hide exactly what the user should be watching. + expect(defaultView(2, 0)).toBe('agents') + // Even with a stocked library, an arriving node still wins over Home. + expect(defaultView(1, 3)).toBe('agents') + }) + + it('opens add-mode only when nothing is installed and nothing is arriving', () => { + expect(defaultView(0, 0)).toBe('install') + }) + + it('opens Home once the library has agents', () => { + expect(defaultView(0, 1)).toBe('home') + }) +}) + +describe('canDecideDefaultRoute', () => { + it('waits while the registry is unreadable and nothing is provisioning', () => { + expect( + canDecideDefaultRoute({ registryExists: false, registryError: 'down', bundledCount: 0 }) + ).toBe(false) + expect( + canDecideDefaultRoute({ registryExists: false, registryError: undefined, bundledCount: 0 }) + ).toBe(false) + }) + + it('decides once the registry reads cleanly, even when empty', () => { + expect( + canDecideDefaultRoute({ registryExists: true, registryError: undefined, bundledCount: 0 }) + ).toBe(true) + }) + + it('decides as soon as provisioning rows exist', () => { + expect( + canDecideDefaultRoute({ registryExists: false, registryError: 'down', bundledCount: 2 }) + ).toBe(true) + }) +}) + +describe('shouldRerouteToBundled', () => { + const base = { + view: 'install' as const, + bundledCount: 1, + deepLinkHandled: false, + userNavigated: false, + alreadyRerouted: false + } + + it('reroutes an untouched add-mode launch when bundled rows arrive', () => { + expect(shouldRerouteToBundled(base)).toBe(true) + }) + + it.each([ + { ...base, view: 'agents' as const }, + { ...base, view: 'home' as const }, + { ...base, bundledCount: 0 }, + { ...base, deepLinkHandled: true }, + { ...base, userNavigated: true }, + { ...base, alreadyRerouted: true } + ])('does not reroute after another routing decision: $view', (args) => { + expect(shouldRerouteToBundled(args)).toBe(false) + }) +}) + +describe('controlPlaneStatus', () => { + const base = (cp: Partial) => + ({ + controlPlane: { + baseUrl: 'http://127.0.0.1:8000', + reachable: false, + recognized: false, + healthy: false, + ...cp + } + }) as AgentFieldSnapshot + + it('is gray until the first snapshot arrives', () => { + expect(controlPlaneStatus(null).tone).toBe('gray') + }) + + it('reports green when healthy and red when unreachable', () => { + expect(controlPlaneStatus(base({ reachable: true, recognized: true, healthy: true }))).toEqual({ + tone: 'green', + label: 'Running' + }) + expect(controlPlaneStatus(base({})).tone).toBe('red') + }) + + it('separates an unhealthy AgentField from a stranger on the port', () => { + expect(controlPlaneStatus(base({ reachable: true, recognized: true })).label).toBe('Unhealthy') + expect(controlPlaneStatus(base({ reachable: true })).label).toBe('Port in use') + }) +}) diff --git a/desktop/src/renderer/src/App.tsx b/desktop/src/renderer/src/App.tsx index 6c9151591..9423bcbdd 100644 --- a/desktop/src/renderer/src/App.tsx +++ b/desktop/src/renderer/src/App.tsx @@ -9,6 +9,7 @@ import { ActivityPanel } from './components/ActivityPanel' import { InstallPanel } from './components/InstallPanel' import { SettingsPanel } from './components/SettingsPanel' import { CloudPanel } from './components/CloudPanel' +import { KeysBanner } from './components/KeysBanner' import { StarBanner } from './components/StarBanner' import { UpdateBanner } from './components/UpdateBanner' @@ -48,6 +49,53 @@ const VIEW_TITLES: Record = { cloud: 'Remote' } +/** + * Cold-launch landing view. Bundled nodes still provisioning win: their rows + * live in the Agents library and watching them arrive is the first thing a + * brand-new user should see — dropping them into the marketplace instead would + * ask them to install what the app is already installing. Otherwise an empty + * library opens add-mode (the `install` view, DESIGN.md §4.11) and a stocked + * one opens Home. + */ +export function defaultView(bundledCount: number, agentCount: number): View { + if (bundledCount > 0) return 'agents' + if (agentCount === 0) return 'install' + return 'home' +} + +/** + * Whether a snapshot carries enough to decide the cold-launch route. The + * registry is read through the control plane, so the first poll after a cold + * autostart sees "no registry" while the server is still coming up — routing + * on that would send a user with a stocked library to the marketplace every + * time. Wait for a readable registry (or provisioning rows, which only exist + * once the control plane answered); until then the initial Home view and its + * control-plane status callout are the right thing to show. + */ +export function canDecideDefaultRoute(args: { + registryExists: boolean + registryError: string | null | undefined + bundledCount: number +}): boolean { + return (args.registryExists && !args.registryError) || args.bundledCount > 0 +} + +export function shouldRerouteToBundled(args: { + view: View + bundledCount: number + deepLinkHandled: boolean + userNavigated: boolean + alreadyRerouted: boolean +}): boolean { + return ( + args.view === 'install' && + args.bundledCount > 0 && + !args.deepLinkHandled && + !args.userNavigated && + !args.alreadyRerouted + ) +} + // ⌘1–⌘5 (Ctrl on Win/Linux) in nav order (DESIGN.md §4.17). const SHORTCUT_VIEWS: View[] = ['home', 'agents', 'activity', 'settings', 'cloud'] @@ -71,8 +119,17 @@ export default function App() { const [startingCp, setStartingCp] = useState(false) /** Agents add-mode opened via the "+ Add agent" header action. */ const [addAgentOpen, setAddAgentOpen] = useState(false) + /** + * The keys banner is on screen. Only App sees the whole banner stack, so it + * carries the signal from the banner that computes it to the one that has to + * yield — the star prompt must not ask for a favour while installed agents + * cannot run. + */ + const [keysBannerShowing, setKeysBannerShowing] = useState(false) const defaultRouteApplied = useRef(false) const deepLinkHandled = useRef(false) + const userNavigated = useRef(false) + const bundledRerouted = useRef(false) useEffect(() => { // Lets styles.css inset window chrome for macOS traffic lights vs the @@ -117,15 +174,45 @@ export default function App() { return () => clearInterval(timer) }, [refresh]) - // Cold-launch default: Agents add-mode (via the `install` view) when the - // library is empty, otherwise Home. Deep links win; do not re-apply on + // Bundled nodes still being provisioned this launch (shared/bundled.ts). + // Derived before the routing effect because the cold-launch view and the + // add-mode decision both hang off it. + const bundled = snapshot?.bundled ?? [] + + // Cold-launch default (see defaultView). Deep links win; do not re-apply on // later polls or remember the last view. useEffect(() => { if (!snapshot || defaultRouteApplied.current) return + if ( + !canDecideDefaultRoute({ + registryExists: snapshot.registry.exists, + registryError: snapshot.registry.error, + bundledCount: bundled.length + }) + ) { + return + } defaultRouteApplied.current = true if (deepLinkHandled.current) return - setView(snapshot.registry.agents.length === 0 ? 'install' : 'home') - }, [snapshot]) + setView(defaultView(bundled.length, snapshot.registry.agents.length)) + }, [snapshot, bundled.length]) + + useEffect(() => { + // The first snapshot normally arrives before main has seeded provisioning + // rows, so the cold-launch default may already have selected add-mode. + if ( + shouldRerouteToBundled({ + view, + bundledCount: bundled.length, + deepLinkHandled: deepLinkHandled.current, + userNavigated: userNavigated.current, + alreadyRerouted: bundledRerouted.current + }) + ) { + bundledRerouted.current = true + setView('agents') + } + }, [view, bundled.length]) const handleStartControlPlane = useCallback(async () => { setStartingCp(true) @@ -144,23 +231,33 @@ export default function App() { const cp = controlPlaneStatus(snapshot) const agents = snapshot?.registry.agents ?? [] const installedNames = agents.map((a) => a.name) + const provisioningNames = bundled + .filter((node) => node.phase === 'pending' || node.phase === 'installing') + .map((node) => node.name) // Agents view, two modes (DESIGN.md §4.11). Add-mode when: the install // deep link addressed it, "+ Add agent" was clicked, or the library is - // empty (the marketplace IS the empty state). + // empty (the marketplace IS the empty state). A launch with bundled nodes + // still arriving is not empty — flipping it into add-mode would hide the + // very rows the app is filling in. const agentsSelected = view === 'agents' || view === 'install' const libraryEmpty = - snapshot !== null && !snapshot.registry.error && agents.length === 0 + snapshot !== null && + !snapshot.registry.error && + agents.length === 0 && + bundled.length === 0 const agentsAddMode = agentsSelected && (view === 'install' || addAgentOpen || libraryEmpty) // Navigation from the sidebar or in-view CTAs closes add-mode so the // Agents view comes back in library mode next time. const navigate = useCallback((v: View) => { + userNavigated.current = true setAddAgentOpen(false) setView(v) }, []) const closeAddMode = useCallback(() => { + userNavigated.current = true setAddAgentOpen(false) setView('agents') }, []) @@ -226,7 +323,15 @@ export default function App() { )} - + {/* Blocked-agents warning sits above the star ask: one reports the + product cannot work, the other is a favour. */} + +
{ipcError &&
{ipcError}
} {cp.tone === 'red' ? ( @@ -266,6 +371,7 @@ export default function App() { (agentsAddMode ? ( void refresh()} libraryCount={agents.length} onBackToLibrary={agents.length > 0 ? closeAddMode : undefined} @@ -273,6 +379,7 @@ export default function App() { ) : ( void refresh()} /> ))} diff --git a/desktop/src/renderer/src/components/AgentsPanel.test.ts b/desktop/src/renderer/src/components/AgentsPanel.test.ts new file mode 100644 index 000000000..d01ede5b2 --- /dev/null +++ b/desktop/src/renderer/src/components/AgentsPanel.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import type { BundledStatus } from '../../../shared/types' +import { rosterKey, visibleBundledRows } from './AgentsPanel' + +const row = (name: string, phase: BundledStatus['phase']): BundledStatus => ({ + name, + description: `${name} description`, + phase, + message: '' +}) + +describe('visibleBundledRows', () => { + it('keeps unmatched rows in their original order', () => { + const bundled = [row('swe-planner', 'pending'), row('pr-af', 'installing')] + expect(visibleBundledRows(bundled, ['other'])).toEqual(bundled) + }) + + it('drops installed and failed rows that already exist in the registry', () => { + const bundled = [row('swe-planner', 'installed'), row('pr-af', 'failed')] + expect(visibleBundledRows(bundled, ['swe-planner', 'pr-af'])).toEqual([]) + }) + + it('keeps an installing row that is not yet in the registry', () => { + const installing = row('pr-af', 'installing') + expect(visibleBundledRows([installing], ['swe-planner'])).toEqual([installing]) + }) + + it('returns an empty list for empty inputs', () => { + expect(visibleBundledRows([], [])).toEqual([]) + }) +}) + +describe('rosterKey', () => { + it('is order-insensitive and depends only on the name set', () => { + expect(rosterKey(['pr-af', 'swe-planner'])).toBe(rosterKey(['swe-planner', 'pr-af'])) + expect(rosterKey(['swe-planner', 'swe-planner'])).toBe(rosterKey(['swe-planner'])) + expect(rosterKey(['swe-planner'])).not.toBe(rosterKey(['pr-af'])) + }) +}) diff --git a/desktop/src/renderer/src/components/AgentsPanel.tsx b/desktop/src/renderer/src/components/AgentsPanel.tsx index 49c76898d..f073427c2 100644 --- a/desktop/src/renderer/src/components/AgentsPanel.tsx +++ b/desktop/src/renderer/src/components/AgentsPanel.tsx @@ -1,7 +1,13 @@ import { useCallback, useEffect, useState } from 'react' import type { ReactElement } from 'react' import { AnimatePresence, m, useReducedMotion } from 'motion/react' -import type { AgentEnvReport, AgentFieldSnapshot, SnapshotAgent } from '../../../shared/types' +import type { + AgentEnvReport, + AgentFieldSnapshot, + BundledPhase, + BundledStatus, + SnapshotAgent +} from '../../../shared/types' import { EnvEditor } from './EnvEditor' import { MenuPopover } from './MenuPopover' import { SkeletonRows } from './Skeleton' @@ -11,6 +17,12 @@ type AgentAction = 'start' | 'stop' | 'restart' | 'uninstall' interface AgentsPanelProps { registry: AgentFieldSnapshot['registry'] | null + /** + * Bundled nodes the app is provisioning for this launch (shared/bundled.ts). + * They are not in the registry yet, so they ride above the installed rows as + * read-only progress until the install lands and the real row replaces them. + */ + bundled: BundledStatus[] /** Called after a lifecycle action so the snapshot refreshes promptly. */ onChanged: () => void } @@ -31,25 +43,55 @@ const BUSY_LABEL: Record = { uninstall: 'Uninstalling…' } -export function AgentsPanel({ registry, onChanged }: AgentsPanelProps): ReactElement { +// First-launch provisioning reads as calm progress, never as a broken agent: +// the badge says what the app is doing, not that something is wrong. +const BUNDLED_LABEL: Record = { + pending: 'Queued', + installing: 'Installing…', + installed: 'Installed', + failed: 'Install failed' +} + +// A failed bundled node is not marked provisioned, so the next launch tries +// again — say so instead of leaving a dead-looking row. +const BUNDLED_FAILED_TITLE = 'This node is retried automatically on the next launch.' + +export function rosterKey(names: readonly string[]): string { + return [...new Set(names)].sort().join('\0') +} + +export function visibleBundledRows( + bundled: BundledStatus[], + registryNames: readonly string[] +): BundledStatus[] { + const installed = new Set(registryNames) + return bundled.filter((node) => !installed.has(node.name)) +} + +export function AgentsPanel({ registry, bundled, onChanged }: AgentsPanelProps): ReactElement { return (
- +
) } -function AgentsBody({ registry, onChanged }: AgentsPanelProps) { +function AgentsBody({ registry, bundled, onChanged }: AgentsPanelProps) { const [busy, setBusy] = useState<{ name: string; action: AgentAction } | null>(null) const [failure, setFailure] = useState<{ name: string; message: string } | null>(null) const [envReports, setEnvReports] = useState>({}) const [expanded, setExpanded] = useState(null) const [confirmUninstall, setConfirmUninstall] = useState(null) const [openMenu, setOpenMenu] = useState(null) + const registryRosterKey = rosterKey(registry?.agents.map((agent) => agent.name) ?? []) + const visibleBundled = visibleBundledRows( + bundled, + registry?.agents.map((agent) => agent.name) ?? [] + ) // Env/secret statuses come from the af CLI + manifests — refreshed on - // mount and after any change, not on the snapshot poll (each refresh - // shells out to `af secrets ls`). + // mount, when the registry roster changes, and after any action. The stable + // set key avoids shelling out to `af secrets ls` on ordinary snapshot polls. const loadEnv = useCallback(() => { window.agentfield .getEnvReports() @@ -60,7 +102,7 @@ function AgentsBody({ registry, onChanged }: AgentsPanelProps) { }) .catch(() => {}) }, []) - useEffect(loadEnv, [loadEnv]) + useEffect(loadEnv, [loadEnv, registryRosterKey]) useEffect(() => { if (openMenu === null) return @@ -84,7 +126,9 @@ function AgentsBody({ registry, onChanged }: AgentsPanelProps) { } // Rarely rendered: App shows the Agents add-mode (marketplace) whenever the // library is empty, so this only covers odd registry states mid-refresh. - if (!registry.exists || registry.agents.length === 0) { + // A launch that is still provisioning bundled nodes is not empty — it is + // not finished — so the provisioning rows suppress this state. + if ((!registry.exists || registry.agents.length === 0) && visibleBundled.length === 0) { return ( + {/* Bundled nodes first: on a first launch these two rows are the whole + view, so the user watches the install stream instead of an empty + panel. They leave the list once the registry carries the real row. */} + + {visibleBundled.map((node) => ( + + ))} + {registry.agents.map((agent) => ( +
+
+
+ + {node.name} + {node.language ? {node.language} : null} +
+ {node.description && {node.description}} + {node.message && ( + + {node.message} + + )} +
+
+ + ) +} + +function BundledBadge({ phase }: { phase: BundledPhase }) { + return ( + + + ) +} + function AgentRow({ agent, report, diff --git a/desktop/src/renderer/src/components/InstallPanel.tsx b/desktop/src/renderer/src/components/InstallPanel.tsx index 2492fa2d9..4e4edd857 100644 --- a/desktop/src/renderer/src/components/InstallPanel.tsx +++ b/desktop/src/renderer/src/components/InstallPanel.tsx @@ -37,6 +37,7 @@ function InstallCheck() { // there is no separate Install view anymore. interface InstallPanelProps { installedNames: string[] + provisioningNames: string[] onInstalled: () => void /** Installed agents count — labels the "Back to installed (N)" affordance. */ libraryCount: number @@ -119,6 +120,7 @@ export function parseRepoSource(input: string): ParsedRepo | null { export function InstallPanel({ installedNames, + provisioningNames, onInstalled, libraryCount, onBackToLibrary @@ -387,6 +389,7 @@ export function InstallPanel({ key={entry.name} entry={entry} installed={installedNames.includes(entry.name)} + provisioning={provisioningNames.includes(entry.name)} installing={installing} phase={phase} confirming={confirming === entry.name} @@ -417,6 +420,7 @@ export function InstallPanel({ function FeaturedCard({ entry, installed, + provisioning, installing, phase, confirming, @@ -430,6 +434,7 @@ function FeaturedCard({ }: { entry: CatalogEntry installed: boolean + provisioning: boolean installing: boolean phase: InstallPhase confirming: boolean @@ -498,7 +503,11 @@ function FeaturedCard({ ) : ( {sourceLabel} )} - {installed ? ( + {provisioning ? ( + + ) : installed ? ( confirming ? (
+
+ ) +} diff --git a/desktop/src/renderer/src/components/StarBanner.tsx b/desktop/src/renderer/src/components/StarBanner.tsx index 339541c16..23c3785e8 100644 --- a/desktop/src/renderer/src/components/StarBanner.tsx +++ b/desktop/src/renderer/src/components/StarBanner.tsx @@ -38,13 +38,20 @@ function milestoneReached(snapshot: AgentFieldSnapshot | null): boolean { interface StarBannerProps { snapshot: AgentFieldSnapshot | null + /** + * The keys banner is showing (App tracks it). Agents that cannot run are a + * blocker, and a star prompt stacked under one asks for a favour while the + * product is broken — so it yields, the same way it yields to an update. + */ + keysBannerShowing?: boolean } /** * Quiet milestone ask for a GitHub star. Reuses the update-banner material; - * never coexists with an undismissed app update (update wins). + * never coexists with an undismissed app update (update wins) or with the + * "needs API keys" banner (a blocked product wins). */ -export function StarBanner({ snapshot }: StarBannerProps) { +export function StarBanner({ snapshot, keysBannerShowing = false }: StarBannerProps) { const [settings, setSettings] = useState(null) const [updateStatus, setUpdateStatus] = useState(null) const [loaded, setLoaded] = useState(false) @@ -65,6 +72,7 @@ export function StarBanner({ snapshot }: StarBannerProps) { if (isSnoozed(settings.starPromptSnoozedUntil)) return null if (!snapshot?.controlPlane.healthy) return null if (updateBannerWouldShow(updateStatus, settings.dismissedUpdateVersion)) return null + if (keysBannerShowing) return null if (!milestoneReached(snapshot)) return null const consume = (patch: Partial) => { diff --git a/desktop/src/renderer/src/styles.css b/desktop/src/renderer/src/styles.css index 95de08952..5939be63b 100644 --- a/desktop/src/renderer/src/styles.css +++ b/desktop/src/renderer/src/styles.css @@ -771,6 +771,35 @@ section > .section-title { color: var(--warn); } +/* Bundled nodes provisioning on first launch. Accent tint, not a semantic + warning: a node that is still arriving is progress, not a fault. Only a + provisioning run that actually failed drops to danger. */ +.badge.provisioning { + background: var(--accent-soft); + color: var(--accent-ink); +} + +.badge.provisioning.failed { + background: color-mix(in oklch, var(--danger) 12%, transparent); + color: var(--danger); +} + +/* Live-work indicator (DESIGN.md §5.2) sized for a badge dot; the global + reduced-motion rule already stills it. */ +.badge.provisioning.installing .badge-dot { + animation: badge-breathe 1.6s ease-in-out infinite; +} + +@keyframes badge-breathe { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.4; + } +} + .badge-dot { width: 6px; height: 6px; @@ -1645,6 +1674,12 @@ section > .section-title { color: var(--text); } +/* Blocked agents are not an offer: the warn tint separates "you must act + before this works" from the accent-soft update/community banners. */ +.keys-banner { + background: color-mix(in oklch, var(--warn) 12%, transparent); +} + /* --- callouts / empty -------------------------------------------------------- */ .callout { diff --git a/desktop/src/shared/bundled.ts b/desktop/src/shared/bundled.ts new file mode 100644 index 000000000..ce7685ba3 --- /dev/null +++ b/desktop/src/shared/bundled.ts @@ -0,0 +1,52 @@ +import type { CatalogEntry } from './types' + +// Agent nodes that ship WITH the app rather than being offered as marketplace +// rows. They are provisioned on first launch (see main/bundledAgents.ts) and +// then live in the Agents library like any other installed node. +// +// "Ships with the app" here means fetched on first launch, not baked into the +// installer: the app installs them through the same control-plane install API +// a user-initiated install uses. Nothing about the packaging changes — only +// who decides to press install, and when. +// +// The entries carry the same shape as CATALOG rows on purpose. catalogEntry() +// resolves over both lists, so update / --force reinstall from the Agents view +// keeps working for a bundled node, and the "the renderer only ever passes a +// vetted NAME over IPC" invariant is preserved because both lists are +// hard-coded here in main-process-trusted source. +// +// Sourcing follows the same rule catalog.ts documents at length: name the BARE +// repo URL, never the `//go` subdirectory. Both repos' root manifests carry +// `superseded_by: …//go`, and that redirect is what carries a user who already +// has the older Python node across — it installs the successor, migrates +// node-scoped secrets, and only then retires the predecessor. Naming `//go` +// would land the same node but skip that migration. +// +// As in the catalog, `name` MUST equal the name the package is REGISTERED +// under after the redirect settles (SWE-AF → swe-planner), because that name +// is how the app detects the node is already installed and stops re-provisioning it. +export const BUNDLED_NODES: readonly CatalogEntry[] = [ + { + name: 'swe-planner', + description: + 'Software factory — turn any issue into a production-ready pull request, end to end', + source: 'https://github.com/Agent-Field/SWE-AF', + language: 'go' + }, + { + name: 'pr-af', + description: 'Code review — deep, evidence-backed review of any GitHub pull request', + source: 'https://github.com/Agent-Field/pr-af', + language: 'go' + } +] + +/** True when this node name ships with the app (never a marketplace row). */ +export function isBundled(name: string): boolean { + return BUNDLED_NODES.some((entry) => entry.name === name) +} + +/** Look up a bundled entry by name. Returns undefined for unknown names. */ +export function bundledEntry(name: string): CatalogEntry | undefined { + return BUNDLED_NODES.find((entry) => entry.name === name) +} diff --git a/desktop/src/shared/catalog.ts b/desktop/src/shared/catalog.ts index 42c39bc10..478463ff6 100644 --- a/desktop/src/shared/catalog.ts +++ b/desktop/src/shared/catalog.ts @@ -1,3 +1,4 @@ +import { BUNDLED_NODES } from './bundled' import type { CatalogEntry } from './types' // Curated list of installable agent nodes, shown in the app's Install view. @@ -15,12 +16,15 @@ import type { CatalogEntry } from './types' // One row per product, sourced at the bare repo URL. A repo that ships more // than one implementation of the same node says which one it wants installed // with `superseded_by:` in its root manifest — the redirect that makes -// `af install ` land on the maintained node (SWE-AF and pr-af both -// point their root at `//go`). Naming `//go` here would install that same -// node, but it would skip the redirect, and the redirect is what carries a -// user who already has the superseded node across: it installs the successor -// first, migrates node-scoped secrets, and only then retires the old package. -// So the catalog names the repo and lets the manifest decide. +// `af install ` land on the maintained node. Naming the subdirectory +// here would install that same node, but it would skip the redirect, and the +// redirect is what carries a user who already has the superseded node across: +// it installs the successor first, migrates node-scoped secrets, and only then +// retires the old package. So the catalog names the repo and lets the manifest +// decide. shared/bundled.ts follows the identical rule for the nodes that ship +// with the app (SWE-AF and pr-af both point their root at `//go`), which is +// why they are no longer rows here: they are provisioned on first launch +// instead of being offered as marketplace cards. // // `name` MUST equal the name the package ends up REGISTERED under once the // install settles — that is how the app detects installed state. Note that is @@ -30,19 +34,6 @@ import type { CatalogEntry } from './types' // this list never names. It is often not the repo name either // (SWE-AF → swe-planner). export const CATALOG: CatalogEntry[] = [ - { - name: 'swe-planner', - description: - 'Software factory — turn any issue into a production-ready pull request, end to end', - source: 'https://github.com/Agent-Field/SWE-AF', - language: 'go' - }, - { - name: 'pr-af', - description: 'Code review — deep, evidence-backed review of any GitHub pull request', - source: 'https://github.com/Agent-Field/pr-af', - language: 'go' - }, { name: 'sec-af', description: @@ -59,7 +50,16 @@ export const CATALOG: CatalogEntry[] = [ } ] -/** Look up a catalog entry by name. Returns undefined for unknown names. */ +/** + * Look up an installable entry by name, across the marketplace catalog AND the + * nodes bundled with the app. Returns undefined for unknown names. + * + * Both lists are hard-coded, so widening the lookup does not widen the trust + * boundary: main/installer.ts still only ever turns a vetted name into a + * vetted source. Including BUNDLED_NODES is load-bearing — it is what keeps a + * bundled node installable and `--force` updatable from the Agents view, even + * though it never appears as a marketplace card. + */ export function catalogEntry(name: string): CatalogEntry | undefined { - return CATALOG.find((entry) => entry.name === name) + return [...CATALOG, ...BUNDLED_NODES].find((entry) => entry.name === name) } diff --git a/desktop/src/shared/types.ts b/desktop/src/shared/types.ts index df7af4efc..351bcaf1f 100644 --- a/desktop/src/shared/types.ts +++ b/desktop/src/shared/types.ts @@ -81,6 +81,23 @@ export interface CatalogEntry { language?: string } +/** Where a bundled node is in its first-launch provisioning. */ +export type BundledPhase = 'pending' | 'installing' | 'installed' | 'failed' + +/** + * One bundled node the app is provisioning (see shared/bundled.ts). These are + * not registry rows: they describe work in flight, so the Agents view can show + * the two nodes that ship with the app arriving before they exist on disk. + */ +export interface BundledStatus { + name: string + description: string + language?: string + phase: BundledPhase + /** Latest progress line, or the error text when phase is 'failed'. '' when none. */ + message: string +} + /** Terminal states of an install kicked off from the app. */ export interface InstallResult { ok: boolean @@ -201,6 +218,12 @@ export interface DesktopSettings { lastControlPlanePort: number | null /** Installed agent names to start once the control plane is healthy. */ autostartAgents: string[] + /** + * Bundled node names this app has already provisioned at least once. A name + * recorded here is never auto-installed again, so uninstalling a bundled + * node sticks across launches instead of coming back on the next start. + */ + provisionedBundled: string[] /** * Keep the AgentField skill catalog (building agents, personal agents, * calling installed ones) installed in detected coding agents (Claude @@ -225,6 +248,13 @@ export interface DesktopSettings { starPrompt: 'pending' | 'done' /** ISO timestamp until which the star prompt is snoozed (Later = +7 days). null = not snoozed. */ starPromptSnoozedUntil: string | null + /** + * Agent names the app has already warned about over a native notification + * after provisioning them without the API keys they require. A name recorded + * here is never announced again, so the notice fires once per provisioning + * event instead of on every launch (see main/keyNotice.ts). + */ + keyNoticeShown: string[] } export interface CloudTestResult { @@ -355,6 +385,12 @@ export interface AgentFieldSnapshot { * existing poll delivers it without a second polling loop in the renderer. */ skillSync: SkillSyncRecord | null + /** + * Bundled nodes still being provisioned this launch. Empty once each one is + * installed or was deliberately uninstalled by the user. Main-process state + * like skillSync, riding the existing snapshot poll rather than a second one. + */ + bundled: BundledStatus[] /** ISO timestamp of when this snapshot was assembled. */ fetchedAt: string } diff --git a/skills/agentfield-use/SKILL.md b/skills/agentfield-use/SKILL.md index bf358dffc..b28eec25c 100644 --- a/skills/agentfield-use/SKILL.md +++ b/skills/agentfield-use/SKILL.md @@ -1,53 +1,107 @@ --- name: agentfield-use -version: 0.6.0 -description: "Discover and call agents already running on a local or cloud AgentField control plane. Use when the user asks to use, call, query, run, or delegate work to an installed AgentField agent (swe-planner, pr-af, sec-af, …), to list what agents or reasoners are available, or to check on an execution. Resolves the right control plane (desktop-configured cloud first), fetches the reasoner's exact contract before dispatching, and only calls entry-point reasoners. Not for building new agents — that is the agentfield skill." +version: 0.8.0 +description: "Whenever you have a discrete task to perform — one the user delegated, or one that arose inside your own work — check FIRST whether an installed AgentField agent covers it, and offload to it by default when one does. Coverage, not task size, is the test: even a small job goes to a covering agent. The check is cheap — one `af ls -e` or capability lookup answers it, and the roster can be cached for the session. These agents are your AgentField subharnesses: workers running on cheaper open models, in parallel, off your own context, every run recorded on the control plane and watchable at /ui/runs/. This skill cannot know what your fleet does — runtime discovery is the only source of truth (swe-planner, pr-af, and whatever else the user installed). Also the reactive path when the user asks to use, call, or run an agent, to list what agents or reasoners exist, or to check on an execution. Resolves the right control plane (local or desktop-configured cloud) first, fetches the reasoner's exact contract before dispatching, and calls entry-point reasoners only. Not for building new agents — that is the agentfield skill." --- -# Using AgentField agents +# AgentField subharnesses — offload work instead of doing it inline A machine with AgentField has one or more **control planes** — a local one -(default `http://localhost:8080`) and possibly a **cloud deployment** -configured in AgentField Desktop — plus **agent nodes** installed under -`~/.agentfield`. Each node exposes **reasoners** — typed functions you call -over HTTP. You never talk to an agent's own port: every call goes through the -control plane, which routes it, records the workflow, and returns the result. - -**Resolve which control plane you are targeting before anything else (§0).** -The local and cloud fleets are disjoint: different agents, different versions, -different filesystems, different run history. Nothing ever falls back from one -to the other on its own. - -A local server in local mode has no auth. A cloud deployment (and any server -with an API key configured) requires `X-API-Key: ` on every request. +(default `http://localhost:8080`) and possibly a **cloud deployment** configured +in AgentField Desktop — plus **agent nodes** installed under `~/.agentfield`. +Each node exposes **reasoners**: typed functions you call through the control +plane, which routes the call, records the run, and returns the result. You never +talk to an agent's own port. + +Those installed agents are **subharnesses**: workers you offload to. They run on +smaller, cheaper models, they run in parallel, they run **off your context**, and +every run is recorded on the control plane and watchable in its web UI. A review +that would cost you 40k tokens of reading costs you one dispatch and one result. + +**Vocabulary rule.** Internally — commands, API fields, discovery output — +everything stays `agent` / `reasoner` / `node`, exactly matching the CLI and API. +In what you SAY TO THE USER, call them subharnesses: "your AgentField +subharnesses", "the pr-af subharness". Never let the wording cost clarity: if the +user needs a command or a node name, give the literal one. + +## Offload by default + +Whenever you have a discrete task — one the user delegated, or one that arose +inside your own work — the first question is not "how do I do this?" but **"does +an installed subharness cover this?"** When one does, dispatching to it is the +default path, not an option to offer. + +**Coverage is the test, not size.** A small job a subharness covers still goes to +the subharness; a large job nothing covers stays with you. This skill carries no +list of offloadable task types and cannot: users install new subharnesses at any +time, so runtime discovery (§1) is the only source of truth about what your fleet +does. + +**The check is cheap — that is the whole design.** One `af ls -e` (or one +capabilities call) answers "does anything cover this?". Discover once per +session, remember the roster, and match later tasks against what you learned; +re-discover only when something suggests the fleet changed — an install, a start, +or a miss you doubt. Consulting this skill on a task nothing covers costs one +cheap lookup. That is the trade, deliberately. + +The contract, in four parts: + +1. **Default-offload.** Do not ask "would you like me to use an agent?" — check + coverage (§1), then dispatch. Work stays inline only when nothing covers it + (§1, "No coverage") or the user said to do it yourself. +2. **Announce it, with a link.** Tell the user what you offloaded, to which + subharness, and the run's live URL — built from the resolved server (§0) plus + the run_id, URL-encoded: `/ui/runs/`. + + > Offloading the PR #42 review to your pr-af subharness — follow along at + > http://localhost:8080/ui/runs/run_abc123 + + The link is **for the user** to watch in parallel. It never replaces your own + monitoring (§4) or your final report (§5). On a cloud target the link only + opens if the user's browser can reach that host — print it anyway, the URL + shape is identical. +3. **The user can always override.** "Do it yourself" ends the offload; do the + work inline and say so. Overrides are per-request, not permanent. +4. **Never silent-wash the offload.** If the offloaded run fails, stalls, or + comes back empty, **report that and ask.** Do NOT quietly redo the work inline + and present the output as if the subharness produced it. The same rule covers + a node that cannot start (§1): never substitute your own work for an agent's + without saying so — the user believes their agent ran. ## 0. Resolve the server first (local vs cloud) +The local and cloud fleets are disjoint: different agents, different versions, +different filesystems, different run history. Nothing ever falls back from one to +the other on its own. A local server in local mode has no auth; a cloud +deployment (and any server with an API key configured) requires +`X-API-Key: ` on every request. + Resolution order — stop at the first match: 1. **Explicit wins.** The user named a server, or `AGENTFIELD_SERVER` is set in the environment → use that. 2. **Read the desktop cloud config.** Check every path that applies to this - machine — a file that exists but declares no enabled cloud does NOT end - the search: + machine — a file that exists but declares no enabled cloud does NOT end the + search: - macOS: `~/Library/Application Support/agentfield-desktop/settings.json` - Windows: `%APPDATA%/agentfield-desktop/settings.json` - Linux: `~/.config/agentfield-desktop/settings.json` - WSL (detect: `grep -qi microsoft /proc/version`): the Linux path above first, then the Windows side, where the desktop app usually lives: `/mnt/c/Users/*/AppData/Roaming/agentfield-desktop/settings.json`. - A Linux-side file with no `cloud` key shadowing a Windows file that - holds the real cloud config is the common split-brain — the enabled - cloud wins, whichever side declares it. + A Linux-side file with no `cloud` key shadowing a Windows file that holds + the real cloud config is the common split-brain — the enabled cloud wins, + whichever side declares it. + The first file declaring `cloud.enabled: true` with a non-empty - `cloud.serverUrl` makes the cloud the target: strip any trailing slash - from the URL and take `cloud.apiKey` as the key. Health-check it + `cloud.serverUrl` makes the cloud the target: strip any trailing slash from + the URL and take `cloud.apiKey` as the key. Health-check it (`GET /health` with `X-API-Key`). - Healthy → use the cloud for everything below. - Unreachable → **stop and tell the user their cloud control plane is - configured but not responding.** Do NOT silently fall back to local: - work dispatched there lands on a different fleet with different - filesystems, which is worse than no dispatch. + configured but not responding.** Do NOT silently fall back to local: work + dispatched there lands on a different fleet with different filesystems, + which is worse than no dispatch. 3. **Otherwise use local:** `http://localhost:8080`. Then pass the target **explicitly on every call**: `af --server -k ` @@ -58,69 +112,51 @@ task; explicit per-call flags are the contract. If you'd rather not pass `-k` each time, `af auth login --server ` stores a key per server in `~/.agentfield/credentials.json`. -## MCP (zero-setup) +Health-check before the first dispatch: `curl -s /health` → `200` with +`{"status":"healthy", ...}`. Connection refused on the **local** target means no +control plane is running — the user can open AgentField Desktop, or you can start +one in the background (`af server` blocks, so background it and poll `/health`). +If the resolved target is the configured **cloud** and this fails, stop and report +it — do not retarget local. -The control plane serves a built-in **MCP server at `/mcp`** (default -`http://localhost:8080/mcp`) — same port, no extra process, on by default. If -your harness speaks MCP, this is the fastest way in. +## The golden path -Claude Code: +Five steps, CLI-first. `af` is installed wherever AgentField is; the HTTP +equivalents are further down for what the CLI can't do and as a fallback. ```bash -claude mcp add --transport http agentfield http://localhost:8080/mcp -# cloud target (§0): -claude mcp add --transport http agentfield https:///mcp --header "X-API-Key: " +af ls -e -s # 1. what can I offload to? +af call pr-af.review --schema -s # 2. the exact contract +RUN_ID=$(af call pr-af.review --in '{"pr":42}' --async -s ) # 3. dispatch +af wait "$RUN_ID" --timeout 300 -o json -s # 4. monitor +# 5. report: result + duration + cost picture + /ui/runs/$RUN_ID ``` -Other MCP clients: point them at the same streamable-HTTP URL -(`http:///mcp`, transport `http`). It's stateless JSON-RPC — no session -setup. If the server has an API key, pass it as an `X-API-Key: ` header in -the client's MCP config. +## 1. Find the subharness — discover, don't guess -Five tools are exposed: `discover_agents`, `get_reasoner_schema`, -`execute_reasoner` (starts an async run, returns a `run_id`), `get_run`, and -`wait_run`. Disable with `AGENTFIELD_MCP_ENABLED=false` (the route then 404s). - -The MCP tools cover the common discover → execute → poll loop. The `af` CLI and -the raw HTTP API below remain the full-power path (sessions, streaming, -cancel-tree, secrets, load-aware pacing); reach for them when a task needs more -than the five tools give you. - -## The flow - -0. Resolve the server (§0) — desktop-configured cloud first, explicit - `--server`/URL on every call. -1. Health-check the control plane. -2. Discover what agents and reasoners exist, and fetch the target reasoner's - exact contract before the first call. -3. Execute — async for anything nontrivial. Fire independent calls concurrently. -4. Poll (or stream) until the execution finishes — and watch for wedged runs. - -## 1. Is the control plane up? +Run this once per session and keep the roster; re-run it only when the fleet may +have changed (an install, a start, or a miss you doubt). ```bash -curl -s http://localhost:8080/health +af ls -e # entry-point reasoners only — the callable surface +af ls [query] # all reasoners across RUNNING agents (not the install registry) +af agent search "review a pull request" # BM25-ranked; --agent , --limit N (max 50) +af list # installed agents + status (source of truth for INSTALLED) ``` -Healthy: `200` with `{"status":"healthy", ...}`. Connection refused on the -**local** target means no control plane is running — the user can open the -AgentField desktop app, or you can start one in the background (`af server` -blocks, so background it and poll `/health` until healthy). If the resolved -target is the desktop-configured **cloud** and this check fails, stop and -report it (§0) — do not retarget local. +`af agent search` hits carry `reasoner_id`, `agent_id`, `invocation_target`, +`tags`, `score`, and `agent_health` — everything needed to dispatch with no second +lookup. Prefer it over dumping the whole capability payload into context once a +box has more than ~20 reasoners. -## 2. Discover agents and reasoners +The durable HTTP discovery endpoint, when you need the whole fleet at once: ```bash curl -s "http://localhost:8080/api/v1/discovery/capabilities?include_input_schema=true" ``` -This is the durable discovery endpoint. Reasoner names are `.reasoners[].id` -(NOT `.name`), and `include_input_schema=true` adds each reasoner's JSON input -schema — read it before calling so your `input` matches. - -Don't assume `jq` exists (fresh Windows boxes lack it) — parse with what's -installed, e.g.: +Reasoner names are `.reasoners[].id` (NOT `.name`). Don't assume `jq` exists +(fresh Windows boxes lack it) — parse with what's installed, e.g.: ```bash curl -s "http://localhost:8080/api/v1/discovery/capabilities?include_input_schema=true" -o caps.json @@ -133,60 +169,52 @@ for c in json.load(open('caps.json'))['capabilities'] or []: # null when no age Three gotchas: - The response's `invocation_target` field uses a **colon** (`agent:reasoner`). - The execute URL uses a **dot**. Build the target yourself: `.`. + The execute target uses a **dot**. Build it yourself: `.`. - Discovery lists **every registered agent, including dead ones** — check `health_status` and only dispatch to `"active"` agents. Dispatching to an `inactive`/`unknown` agent queues work that never runs. -- Installed-but-never-started agents may not appear at all. The local registry - is the source of truth for what's installed: `af list`, start with - `af run ` (it detaches; the agent keeps running after the CLI exits). - -### Too many reasoners to scan? Search, don't dump - -When a box has more than ~20 reasoners installed, ranked search beats reading -the whole capabilities payload into context: +- Installed-but-never-started agents may not appear at all. Discovery lists what + is RUNNING; `af list` is the source of truth for what is INSTALLED. This is the + normal first-run state, not an edge case — the desktop app ships `swe-planner` + and `pr-af` pre-provisioned but deliberately NOT started, because they need API + keys the user hasn't entered yet. + +### Start it before you dispatch — the start attempt is the diagnostic + +If the node you need is in `af list` but absent from discovery, or its +`health_status` isn't `active`, run `af run ` BEFORE dispatching (it +detaches; the agent keeps running after the CLI exits). Do this first, not as a +fallback after a failed call: a node blocked on an unset key never registers, so +every call to it comes back as the useless `agent 'X' not found`, while `af run` +names the exact variable and exits 1. + +`af run` reads the encrypted store (`~/.agentfield/secrets/*.enc`) — the same +store that gates startup — so it is the only authoritative check that a node's +keys are set. **Do not use `af doctor` or `af config --list` to decide +whether a key is configured**: doctor reads only the process environment +(`os.Getenv`) and `config --list` reads the package `.env` file, so both report a +correctly-stored key as `✗ unset`, and neither renders `require_one_of` groups. +`af secrets ls` shows what IS stored but never cross-references manifests, so it +can't tell you a required key is missing. + +**A missing key is a blocking handoff, not a problem to route around.** When +`af run` fails with -```bash -af agent search "review a pull request" # BM25-ranked; --agent , --limit N (max 50) -# or: curl -s "http://localhost:8080/api/v1/agentic/reasoners?q=review+pull+request" ``` - -Each hit carries `reasoner_id`, `agent_id`, `invocation_target`, `tags`, -`score`, and `agent_health` — everything you need to dispatch with no second -lookup. Build the execute target straight from `invocation_target` (colon → dot) -and only dispatch to hits whose `agent_health` is `"active"`. - -### Fetch the exact contract before you dispatch — never guess inputs - -Search and discovery tell you a reasoner exists; they do not license a call. -Before the first call to any reasoner, read its contract: - -```bash -af agent agent-summary --id -s # all of an agent's reasoners: descriptions + input/output schemas + health + 24h metrics -# single reasoner via MCP: get_reasoner_schema -# or the fleet at once: curl -s "/api/v1/discovery/capabilities?include_input_schema=true" +node swe-planner: missing required environment variables: OPENROUTER_API_KEY (af secrets set OPENROUTER_API_KEY --node swe-planner) ``` -Read BOTH the description and the input schema, and follow them literally: - -- A schema of `{"type":"object"}` with no properties is NOT "anything goes" — - it means the agent registered no schema and **the description text is the - entire contract**. Field names, required-ness, and types stated there are - binding (e.g. swe-pro's `code_task`: `goal` and an **absolute** `dir` are - required; model pools are comma-separated strings, not arrays). -- Result semantics live in the description too. Some agents report a failed - job in the RESULT (`status: "fail"`) while the execution itself reads - `succeeded` — check the result's own status field, not just the execution's. +— or, for an alternatives group, `at least one of ANTHROPIC_API_KEY or +OPENROUTER_API_KEY is required — set one with: …` — the value exists only in the +user's head. Stop and tell them: the exact variable(s) the error names, the exact +`af secrets set … --node ` command copied verbatim from it, and that the +same key can be entered in AgentField Desktop → Agents → → Keys. Then wait. -### Entry points only — undescribed reasoners are internal - -Agents register their internal pipeline stages alongside their public flows, -and discovery lists all of them. Dispatch ONLY to reasoners that carry the -`entrypoint` tag or a description. A reasoner with no description (e.g. -swe-planner's `run_*` stages) or tagged `internal` is plumbing invoked by an -orchestrator — calling it directly fails or corrupts a run. `af ls -e` lists -tagged entry points; when in doubt, pick the described reasoner whose -description names your use case. +Do NOT retry `af run`, do NOT dispatch to the node anyway, do NOT substitute a +different agent, and do NOT quietly do the job yourself instead — a silent +substitution is the worst outcome, because the user believes their agent ran. +Never ask the user to paste the secret value into the conversation; the CLI +prompt and the desktop form take it directly. ### No coverage: offer to build it @@ -199,8 +227,9 @@ job; a similar name or tag alone is not coverage. If discovery finds a stopped-but-capable installed agent, explain that it can be started with `af run `; do not offer a replacement build. If those checks establish that no installed reasoner supports the requested job, say explicitly: -**"No capable installed agent was found for this job."** Then offer to build the -missing capability: with the `agentfield-personal` skill when the user wants an +**"No capable installed agent was found for this job."** Then do the work inline +yourself (that is the honest fallback — say you are doing it), and offer to build +the missing capability: with the `agentfield-personal` skill when the user wants an agent installed on this machine, or with the `agentfield` skill for a standalone project repository. @@ -210,90 +239,152 @@ building an agent. Hand off to a builder skill only when the original request already authorized creating an agent, or when the user explicitly accepts this offer. -## 3. Call a reasoner +## 2. Fetch the contract -Input kwargs are ALWAYS nested under `"input"` — never raw at the top level. +### Fetch the exact contract before you dispatch — never guess inputs -**Async — the default for real work.** Returns `202` immediately: +Search and discovery tell you a reasoner exists; they do not license a call. +Before the first call to any reasoner, read its contract: ```bash -curl -s -X POST http://localhost:8080/api/v1/execute/async/swe-planner.plan \ - -H 'Content-Type: application/json' \ - -d '{"input": {"task": "add rate limiting to the API"}}' -# -> {"execution_id":"...", "run_id":"...", "status":"queued", ...} +af call . --schema # prints the input schema and exits +af agent agent-summary --id # all of an agent's reasoners: descriptions + input/output schemas + health + 24h metrics +# single reasoner via MCP: get_reasoner_schema +# or the fleet at once: curl -s "/api/v1/discovery/capabilities?include_input_schema=true" ``` -**Sync — only for calls that finish fast** (hard 90s timeout, response carries -`result` directly): +Read BOTH the description and the input schema, and follow them literally: + +- A schema of `{"type":"object"}` with no properties is NOT "anything goes" — it + means the agent registered no schema and **the description text is the + entire contract**. Field names, required-ness, and types stated there are binding + (e.g. swe-pro's `code_task`: `goal` and an **absolute** `dir` are required; + model pools are comma-separated strings, not arrays). +- Result semantics live in the description too. Some agents report a failed job + in the RESULT (`status: "fail"`) while the execution itself reads `succeeded` — + check the result's own status field, not just the execution's. + +### Entry points only — undescribed reasoners are internal + +Agents register their internal pipeline stages alongside their public flows, and +discovery lists all of them. Dispatch ONLY to reasoners that carry the +`entrypoint` tag or a description. A reasoner with no description (e.g. +swe-planner's `run_*` stages) or tagged `internal` is plumbing invoked by an +orchestrator — calling it directly fails or corrupts a run. `af ls -e` lists +tagged entry points; when in doubt, pick the described reasoner whose description +names your use case. + +## 3. Dispatch ```bash -curl -s -X POST http://localhost:8080/api/v1/execute/swe-planner.plan \ - -H 'Content-Type: application/json' \ - -d '{"input": {"task": "..."}}' +RUN_ID=$(af call swe-planner.plan --in '{"task":"add rate limiting to the API"}' --async) +# -> bare run_id on stdout; with -o json: {"run_id":"…","status":"accepted"} ``` +What `af call` does for you: it fetches the schema and **validates your input +client-side before dispatch**, so a bad payload fails locally instead of burning +a run. `--in` also takes `@file.json` / `@file.yaml`, and piping JSON to stdin +works. `--field .path.to.field` extracts a single field from a result. + +- **With `af call --in`, pass the kwargs at the top level** — the CLI wraps them + under `"input"` for you. Over raw HTTP you nest them yourself (§HTTP). +- **Always pass `--async` from a harness.** Without it, `af call` on a TTY + auto-tails the run; but a harness's stdout is *not* a TTY, and there it falls + back to the **synchronous** endpoint with its hard 90s timeout. Async + + monitor is the offload path; sync is for quick lookups only. +- If an interactive `af call` is interrupted it prints + `Detached. Resume with: af tail ` — the run is still going. + ### Concurrency — use it -Async dispatch is cheap: fire all independent calls up front, then poll them +Async dispatch is cheap: fire all independent calls up front, then monitor them together. Do NOT serialize multi-agent work — the whole point of the control -plane is managing many agents at once. When a batch of independent jobs arrives -(ten PRs to review, five repos to scan), the default is to dispatch the whole -batch now and poll as a group — not one-at-a-time. What to know: +plane is managing many subharnesses at once. When a batch of independent jobs +arrives (ten PRs to review, five repos to scan), the default is to dispatch the +whole batch now and poll as a group — not one-at-a-time. What to know: - Concurrent calls to the **same reasoner** are safe when the agent is (e.g. pr-af isolates concurrent reviews per PR). If an agent's docs don't say it's - parallel-safe, assume same-target calls may contend on shared state and - stagger them; different agents never contend. Some agents serialize ALL - executions process-wide (swe-pro queues concurrent `code_task` calls behind - one lock) — the reasoner description says so when known; dispatching more - than one heavy call to such a node just builds a queue. + parallel-safe, assume same-target calls may contend on shared state and stagger + them; different agents never contend. Some agents serialize ALL executions + process-wide (swe-pro queues concurrent `code_task` calls behind one lock) — the + reasoner description says so when known; dispatching more than one heavy call + to such a node just builds a queue. - Each call fans out inside the agent (one review ≈ dozens of sub-executions, several LLM CLI processes). 3–4 heavy runs per node is a sensible ceiling unless the agent documents otherwise. -- Save every `execution_id` you dispatch. Group related calls with an - `X-Session-ID` header so they're queryable as one batch later. +- Save every `run_id` you dispatch — you need them to monitor, to report, and for + the audit trail. Group related calls with an `X-Session-ID` header so they're + queryable as one batch later. **Check the load before piling on.** Every `af agent` / agentic response carries `meta.load`: `{running_agents, total_agents, active_executions, cpu_cores, recommended_max_concurrent}` (the recommendation is CPU-based). Read it before launching more heavy runs — if `active_executions >= recommended_max_concurrent`, -finish or await in-flight work first rather than starting more, and tell the -user you're throttling to avoid overloading the machine. +finish or await in-flight work first rather than starting more, and tell the user +you're throttling to avoid overloading the machine. **Canary after reconfiguration, then fan out.** The one exception to fire-everything-up-front: you just changed a node's runtime config (provider, model, bin path — `af secrets set` + restart). A misconfigured harness can fail *silently* — the run reports `succeeded` with empty results in seconds, and an agent that posts externally (GitHub reviews, Slack, tickets) will publish that -garbage under the user's identity, once per dispatched call. So after any -config change: send ONE representative call, confirm it did real work (nonzero -cost/duration, plausible output — not just `succeeded`), then fan out the rest -at full width. This is a gate on the first call after a config change, not a -reason to serialize steady-state work. +garbage under the user's identity, once per dispatched call. So after any config +change: send ONE representative call, confirm it did real work (plausible output, +a real `duration_ms`, and nonzero cost in the `usage/stats` window — not just +`succeeded`), then fan out the rest at full width. This is a gate on the first +call after a config change, not a reason to serialize steady-state work. + +## 4. Monitor — pick the retrieval mode + +| Situation | Do this | +|---|---| +| Short job (≤ a few minutes) | `af wait --timeout 300 -o json` — blocks until terminal, prints `{run_id, status, result}` | +| Long single job the user is watching | `af tail ` — live execution event stream (`--from N` resumes at a step) | +| Long job, or many jobs in flight | Save every run_id; poll as a group with backoff (start ~5s, settle ~30s): `af ps`, `POST /api/v1/executions/batch-status`, `GET /api/v1/executions/active` | +| Unattended service or CI — **not a coding harness** | Register a `webhook` on the execute request (below) | + +`af wait` polls `/api/v1/agentic/run/:run_id` every 2s; default `--timeout` is +600s. **Exit code 2 means TIMEOUT, not failure** — the run is still going. Wait +again with a longer timeout, or switch to group polling. Exit 1 is a genuinely +failed run. + +**Webhooks are not for you.** The execute request body (sync and async) accepts +`"webhook": {"url": "…", "secret": "…", "headers": {…}}`; the response carries +`webhook_registered` (plus `webhook_error` on async) and the execution status +carries `webhook_events`. That is for **services and CI that run an HTTP +listener**. A coding harness has no listener and must never register one and wait +— use wait / tail / poll. + +**What's in flight right now** — no IDs needed: `af ps` (`--agent `, +`--session `), or `GET /api/v1/executions/active` (filters: `?agent_id=`, +`?session_id=`), which returns per-run `active_executions`, `total_executions`, +`started_at`, `latest_activity`. -## 4. Get the result - -**What's in flight right now** — no IDs needed (also answers "how many agents -are running something"): - -```bash -curl -s http://localhost:8080/api/v1/executions/active -# {"count":2,"runs":[{"run_id":"...","target":"pr-af.review","root_status":"running", -# "active_executions":4,"total_executions":27,"started_at":"...","latest_activity":"..."}]} -``` +**Several at once:** `POST /api/v1/executions/batch-status` with +`{"execution_ids": [...]}`. Terminal entries embed the FULL result payload — +responses can be large (100KB+), so write to a file and parse from there; never +pass the response through a command-line argument (Windows caps argv ~32KB). -Filters: `?agent_id=`, `?session_id=`. CLI equivalent: `af ps`. +There is **no** `GET /api/v1/executions` list endpoint — use `/executions/active` +for in-flight work and `POST /api/v1/agentic/query` (body: +`{"resource":"runs","filters":{"status":"..."},"limit":20}`) for history. -**One execution** — poll until `status` is terminal (`succeeded` / `failed`, -also `cancelled` / `timeout`): +### Wedge protocol — "running" is not proof of progress -```bash -curl -s http://localhost:8080/api/v1/executions/ -``` +An execution can report `running` indefinitely after its agent silently dies or +deadlocks. Treat a run as suspect when `/executions/active` shows +`latest_activity` **more than ~10 minutes old** while `active_executions > 0` AND +`af logs ` shows nothing new for that run. (A quiet log alone is not proof +— one long LLM completion can be minutes of legitimate silence.) Then: -Long-running agents can take tens of minutes — poll with backoff (start ~5s, -settle at ~30s) and tell the user what is in flight. For live progress, stream -Server-Sent Events from `GET /api/v1/executions//events`. +1. Cancel the WHOLE run, not just the root: + `POST /api/v1/workflows//cancel-tree` (bottom-up, cancels children + too). Plain `/executions//cancel` cancels ONLY that execution — children + keep "running" and must be cancelled individually. +2. Restart the agent if it's wedged: `af stop && af run `. +3. Re-submit the work — and tell the user it wedged and was re-submitted. A + wedged run is a reportable event, not something to paper over. ### If the result carries a `workspace_handle`, you can read the files @@ -311,8 +402,7 @@ when it works and absent when it doesn't. `furrow` is rarely on PATH. AgentField installs it to `$AGENTFIELD_HOME/bin/` (default `~/.agentfield/bin/`), and a node that ships its own copy keeps it inside the installed package. Resolve it from those; do not try to install it -yourself. POSIX sh only — no brace expansion, so the package dirs are spelled -out. +yourself. POSIX sh only — no brace expansion, so the package dirs are spelled out. ```sh os=$(uname -s | tr A-Z a-z) @@ -354,45 +444,59 @@ so change files between issues or on a fork rather than while the agent writes. `POST /api/v1/execute/.get_workspace_handle` with `{"input":{"run_id":"..."}}`. `{"available": false}` means no mirror — carry on without it. Not every build ships this reasoner: check the agent's reasoner list (discovery or -`agent-summary`) before calling it; if it's absent, the node predates the -mirror feature and results simply never carry a handle. +`agent-summary`) before calling it; if it's absent, the node predates the mirror +feature and results simply never carry a handle. -**Several at once:** `POST /api/v1/executions/batch-status` with -`{"execution_ids": [...]}`. Terminal entries embed the FULL result payload — -responses can be large (100KB+), so write to a file and parse from there; never -pass the response through a command-line argument (Windows caps argv ~32KB). +## 5. Report back -There is **no** `GET /api/v1/executions` list endpoint — use `/executions/active` -for in-flight work and `POST /api/v1/agentic/query` (body: -`{"resource":"runs","filters":{"status":"..."},"limit":20}`) for history. +Close every offload with: the result, the run's `duration_ms`, the live URL +`/ui/runs/` (run_id URL-encoded), and — when the user would care +about spend — the cost picture. Keep the run_id in the transcript; it is the +handle for the audit trail and for any follow-up. -### Wedge protocol — "running" is not proof of progress +If the run failed, timed out, wedged, or returned an empty result on nontrivial +input: say so, show what you know (`af logs `, the error message), and ask +how to proceed. Do not fill the gap with your own inline work presented as the +subharness's. -An execution can report `running` indefinitely after its agent silently dies or -deadlocks. Treat a run as suspect when `/executions/active` shows -`latest_activity` **more than ~10 minutes old** while `active_executions > 0` -AND `af logs ` shows nothing new for that run. (A quiet log alone is not -proof — one long LLM completion can be minutes of legitimate silence.) Then: +### Cost: window aggregate, not per-run -1. Cancel the WHOLE run, not just the root: - `POST /api/v1/workflows//cancel-tree` (bottom-up, cancels children - too). Plain `/executions//cancel` cancels ONLY that execution — children - keep "running" and must be cancelled individually. -2. Restart the agent if it's wedged: `af stop && af run `. -3. Re-submit the work. +Per-execution usage (tokens, provider, model, harness, `cost_usd`) IS ingested and +stored keyed by run, but the only exposed API is the aggregate: + +```bash +curl -s "http://localhost:8080/api/ui/v1/usage/stats?window=1h" +# window=1h|24h|7d|30d|all (default 24h) -> {totals, by_model, by_provider, by_agent, by_harness} +``` + +There is **no per-run cost endpoint today.** So after finishing a batch of +offloaded work, when the user would care, report the cost picture from +`usage/stats` — e.g. the 1h window's `by_agent` entry for the node you used — +stating plainly that it is a window aggregate for that agent, not an exact +per-run figure. Never invent a per-run number by dividing or estimating. +`duration_ms` IS exact and per-execution (it is in the execute and status +responses). **Duration is per-run truth; cost is window truth.** ## Sessions and multi-call work - `X-Session-ID: ` on execute requests groups multi-turn work; the control plane forwards it to the agent and scopes session memory by it. -- Reuse `X-Run-ID` across several execute calls to group them into one - workflow; each response also returns its `run_id`. +- Reuse `X-Run-ID` across several execute calls to group them into one workflow; + each response also returns its `run_id`. Agents share state through control-plane memory if you need to pass artifacts around: `POST /api/v1/memory/set` with `{"key": ..., "data": , "scope": "global"}` and `POST /api/v1/memory/get` with `{"key": ...}` (non-global scopes resolve from the `X-Workflow-ID` / `X-Session-ID` / `X-Actor-ID` headers). +## Audit trail + +Every execution is recorded — that is part of what makes offloading better than +inline work. When provenance matters (or the user asks "what did the agents +actually do"), fetch the verifiable-credential chain for a workflow: +`GET /api/v1/did/workflow//vc-chain` (available when DID/VC is enabled), +and verify offline with `af verify audit.json`. + ## When things fail | Symptom | Meaning | Fix | @@ -401,10 +505,75 @@ resolve from the `X-Workflow-ID` / `X-Session-ID` / `X-Actor-ID` headers). | desktop-configured cloud unreachable | cloud deployment down, or URL/key stale | stop and tell the user (§0) — never silently retarget local | | 401/403 from a cloud target | missing or wrong `X-API-Key` | key from desktop `settings.json` `cloud.apiKey`, or `af auth login --server ` | | agent `inactive` in discovery / missing | node installed but not running (or not installed) | `af list`, then `af run ` — or `af install ` | -| `missing required environment variables: X` from `af run` | required key not configured | `af secrets set X` (value via stdin/arg; `--node ` for node-scoped) — or desktop app → Agents → Keys | +| HTTP **400** `{"error":"agent 'X' not found","error_category":"internal_error"}` | the node never registered — usually installed but not started (it's 400, not 404) | `af list` → `af run ` → read the error it prints → hand off if it's a missing key | +| MCP: `target "X.y" not found. Call discover_agents to list available agents and reasoners.` | same cause, seen through MCP | same path: `af list` → `af run ` → hand off | +| `missing required environment variables: X` from `af run` | required key not configured; the node cannot start | **stop and hand off** — give the user the `af secrets set X --node ` line verbatim, or desktop → Agents → → Keys. Never retry, substitute another agent, or do the work yourself | +| `af doctor` / `af config --list` reports a key as unset | they read `os.Getenv` and the package `.env`, not the encrypted store | ignore them for this question — `af run ` is the only authoritative check | +| `af wait` exits **2** | TIMEOUT, not failure — the run is still going | wait again with a longer `--timeout`, or switch to `af tail` / group polling. Exit 1 is the real failure | +| `af call` fails locally before dispatch | client-side schema validation rejected your input | re-read `af call --schema`; fix the payload — nothing was queued | | HTTP 502 with `error_message` | the agent itself errored | read `af logs `, fix, retry | -| execution `running` but latest_activity stale & logs quiet | wedged run | wedge protocol above: cancel-tree → restart agent → re-submit | -| result claims success with zero findings/output on nontrivial input | possible silent tool failure inside the agent | check `af logs ` for that run before trusting it | +| execution `running` but latest_activity stale & logs quiet | wedged run | wedge protocol above: cancel-tree → restart agent → re-submit, and tell the user | +| result claims success with zero findings/output on nontrivial input | possible silent tool failure inside the agent | check `af logs ` for that run before trusting it — and report it, don't redo it silently | + +## MCP (zero-setup) + +The control plane serves a built-in **MCP server at `/mcp`** (default +`http://localhost:8080/mcp`) — same port, no extra process, on by default. If +your harness speaks MCP, this is the fastest way in. + +```bash +claude mcp add --transport http agentfield http://localhost:8080/mcp +# cloud target (§0): +claude mcp add --transport http agentfield https:///mcp --header "X-API-Key: " +``` + +Other MCP clients: point them at the same streamable-HTTP URL +(`http:///mcp`, transport `http`). It's stateless JSON-RPC — no session +setup. If the server has an API key, pass it as an `X-API-Key: ` header in +the client's MCP config. + +Five tools are exposed: `discover_agents`, `get_reasoner_schema`, +`execute_reasoner` (starts an async run, returns a `run_id`), `get_run`, and +`wait_run`. Disable with `AGENTFIELD_MCP_ENABLED=false` (the route then 404s). + +The MCP tools cover the common discover → execute → poll loop. The `af` CLI and +the raw HTTP API remain the full-power path (sessions, streaming, cancel-tree, +secrets, load-aware pacing); reach for them when a task needs more than the five +tools give you. + +## HTTP API — where the CLI can't reach + +Use these for webhook registration, batch status, memory, cancel-tree, and +`X-Session-ID`/`X-Run-ID` headers — or as the whole path when `af` isn't +installed. **Over raw HTTP, input kwargs are ALWAYS nested under `"input"`** — +never raw at the top level. Empty input is `{"input": {}}`. + +```bash +# async — the default for real work; returns 202 immediately +curl -s -X POST http://localhost:8080/api/v1/execute/async/swe-planner.plan \ + -H 'Content-Type: application/json' \ + -H 'X-Session-ID: my-batch-1' \ + -d '{"input": {"task": "add rate limiting to the API"}}' +# -> {"execution_id":"...", "run_id":"...", "status":"queued", ...} + +# sync — quick lookups only (hard 90s timeout; response carries result + duration_ms) +curl -s -X POST http://localhost:8080/api/v1/execute/swe-planner.plan \ + -H 'Content-Type: application/json' -d '{"input": {"task": "..."}}' + +# one execution — poll until status is terminal (succeeded/failed/cancelled/timeout) +curl -s http://localhost:8080/api/v1/executions/ +# live progress as Server-Sent Events +curl -s http://localhost:8080/api/v1/executions//events + +# service/CI only: register a webhook at dispatch time +curl -s -X POST http://localhost:8080/api/v1/execute/async/pr-af.review \ + -H 'Content-Type: application/json' \ + -d '{"input":{"pr":42},"webhook":{"url":"https://ci.example/hook","secret":"s3cr3t"}}' +``` + +Batched API reads in one round trip: `POST /api/v1/agentic/batch` with +`{"operations":[{"id":"op1","method":"GET","path":"/api/v1/agentic/status"}]}` +(CLI: `af agent batch -f operations.json`). ## Local ops cheat sheet (af CLI) @@ -415,39 +584,53 @@ every invocation when the resolved target is the cloud (§0). af list # installed agents + status af ls [query] # search reasoners across running agents (NOT the install registry) af ls -e # only entry-point reasoners — the callable surface +af agent search "" # ranked reasoner search af agent agent-summary --id # full contract: reasoners, schemas, health, 24h metrics +af call . --schema # input schema only +af call . --in '' --async # dispatch; prints run_id +af wait [--timeout N] # block until terminal (exit 2 = timeout) +af tail # attach to the live event stream af ps # in-flight runs across all agents (af ps --agent ) af run # start (detached); af stop af logs # agent logs (-f follows; no per-run filter — grep by run_id) -af secrets set KEY # store an API key (encrypted; prompts for value) +af secrets set KEY [--node ] # store an API key (encrypted; prompts for value) af secrets ls # what's configured (values never shown) af install # install a new agent node ``` -## Audit trail - -Every execution is recorded. When provenance matters (or the user asks "what -did the agents actually do"), fetch the verifiable-credential chain for a -workflow: `GET /api/v1/did/workflow//vc-chain` (available when DID/VC -is enabled), and verify offline with `af verify audit.json`. - ## Hard rules +- **Offload by default.** Any task an installed subharness covers goes to that + subharness — whatever its size — announced with its `/ui/runs/` + link, not offered as an option and not done inline by habit. Coverage is the + test; check it (cheaply, once per session) before doing the work yourself. +- **Never silent-wash an offload.** A failed, stalled, or empty run is reported + and asked about. Never redo it inline and present it as the subharness's work, + and never substitute your own work for an agent that can't start. +- Say "subharness" to the user; keep `agent` / `reasoner` / `node` in commands, + fields, and anything the user has to type. - Resolve the server per §0 and pass it explicitly (`--server` / full URL) on - every call. A desktop-configured cloud beats the local default; an - unreachable configured cloud is a stop-and-report, never a silent fallback. -- Fetch the reasoner's contract before the first call. A vacuous schema means - the description is the contract — follow it literally. + every call. A desktop-configured cloud beats the local default; an unreachable + configured cloud is a stop-and-report, never a silent fallback. +- Fetch the reasoner's contract before the first call. A vacuous schema means the + description is the contract — follow it literally. - Dispatch only to `entrypoint`-tagged or described reasoners. Undescribed or `internal`-tagged reasoners are pipeline stages — never call them directly. - Every call goes through the control plane — never POST to an agent's own port. The one exception is a `workspace_handle`: its `ssh://` endpoint is a furrow transport, not the agent's HTTP port, and the per-run token in the handle is what authorizes it. Reading files there is not an agent call. -- Kwargs live under `"input"`. Empty input is `{"input": {}}`. -- Async + poll for anything that might exceed a few seconds; sync is for quick - lookups only. Independent async calls go out together, not one at a time. -- Only dispatch to agents whose discovery `health_status` is `"active"`. +- Over HTTP, kwargs live under `"input"` (`{"input": {}}` when empty). With + `af call --in`, pass them at the top level — the CLI nests them. +- `--async` + monitor for anything that might exceed a few seconds; sync is for + quick lookups only. Independent calls go out together, not one at a time. +- Never register a webhook and wait for it — a coding harness has no listener. +- Only dispatch to agents whose discovery `health_status` is `"active"`. If it + isn't there, `af run ` first — and if that reports a missing required + environment variable, stop and hand off to the user with the exact key name and + `af secrets set` command. Never retry it, work around it, or substitute. +- Report duration from `duration_ms` (exact) and cost from `usage/stats` (a + window aggregate). Never state a per-run cost — there is no such endpoint. - Don't guess endpoints. The surface above is the contract; if something is missing, ask `GET /api/v1/agentic/discover?q=` before inventing a route. - Building or modifying an agent (new reasoners, scaffolds, deploys) is the