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 b28eec25c..ae01121b4 100644 --- a/control-plane/internal/skillkit/skill_data/agentfield-use/SKILL.md +++ b/control-plane/internal/skillkit/skill_data/agentfield-use/SKILL.md @@ -6,6 +6,9 @@ description: "Whenever you have a discrete task to perform — one the user dele # AgentField subharnesses — offload work instead of doing it inline +If `AGENTFIELD_HARNESS_DEPTH` is set, you are running inside an AgentField +harness session: do not dispatch to AgentField agents unless explicitly asked. + 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`. diff --git a/control-plane/internal/skillkit/skill_data/agentfield/SKILL.md b/control-plane/internal/skillkit/skill_data/agentfield/SKILL.md index 897ff985f..dad3e7c9b 100644 --- a/control-plane/internal/skillkit/skill_data/agentfield/SKILL.md +++ b/control-plane/internal/skillkit/skill_data/agentfield/SKILL.md @@ -7,6 +7,9 @@ aliases: [agentfield-multi-reasoner-builder] # AgentField +If `AGENTFIELD_HARNESS_DEPTH` is set, you are running inside an AgentField +harness session: do not dispatch to AgentField agents unless explicitly asked. + You are a **systems architect**. Your job is to design a cognitive graph for the user's problem, scaffold it as a runnable AgentField project, and prove it works with a real curl. The intelligence is in the composition. Individual LLM calls reason at ~0.3 — a deliberately-shaped graph of ten of them can reach 0.8 on a real problem. Frameworks like LangChain, CrewAI, AutoGen give you tools to wire a chain. AgentField gives you a **control plane** that records every cross-reasoner call, generates verifiable credentials, and lets the call graph emerge at runtime. diff --git a/control-plane/internal/skillkit/target_opencode.go b/control-plane/internal/skillkit/target_opencode.go index 13887f010..5e4fac198 100644 --- a/control-plane/internal/skillkit/target_opencode.go +++ b/control-plane/internal/skillkit/target_opencode.go @@ -9,7 +9,15 @@ import ( "time" ) -// opencodeTarget installs skills where OpenCode discovers them natively. +// opencodeTarget installs skills where OpenCode discovers them natively: a +// directory at ~/.config/opencode/skills//, symlinked at the canonical +// versioned store so updates flow through without rewriting anything OpenCode +// owns. +// +// Older af binaries instead appended a marker block to +// ~/.config/opencode/AGENTS.md. Every install/uninstall now strips that block +// so upgrading users are left with the native skill instead of the native +// skill plus stale instructions. type opencodeTarget struct{} func init() { RegisterTarget(opencodeTarget{}) } @@ -30,6 +38,18 @@ func (opencodeTarget) TargetPath() (string, error) { return filepath.Join(h, ".config", "opencode", "skills"), nil } +// legacyRulesPath is the file older af binaries appended marker blocks to. It +// is derived from an already-resolved skills root, so unlike Codex's variant +// it cannot fail: every caller has proven TargetPath() succeeds before it gets +// here. +// +// Unlike Codex's AGENTS.override.md — a file af created for itself — this one +// is authored by the user and read by OpenCode, so it is only ever read, and +// only rewritten when it still holds a block of ours. +func (opencodeTarget) legacyRulesPath(root string) string { + return filepath.Join(filepath.Dir(root), "AGENTS.md") +} + func (t opencodeTarget) skillLink(skill Skill) (string, error) { root, err := t.TargetPath() if err != nil { @@ -62,6 +82,20 @@ func (t opencodeTarget) Install(skill Skill, canonicalCurrentDir string) (Instal if err := os.Symlink(canonicalCurrentDir, link); err != nil { return InstalledTarget{}, fmt.Errorf("symlink %s -> %s: %w", link, canonicalCurrentDir, err) } + // The native skill is in place; finish the migration off the old + // AGENTS.md block so the user is not left carrying both. + // + // AGENTS.md belongs to the user, and by this point the integration is + // already live on disk. Failing the install over a file the skill does + // not need would push the caller down its failure path, which records + // nothing in state — leaving `af skill list` reporting OpenCode as not + // installed and every later install exiting non-zero, over a stale block + // that has nothing to do with whether OpenCode can load the skill. So the + // cleanup is advisory here and only Uninstall, where the block is the + // whole point of the call, treats it as fatal. + if err := t.removeLegacyMarkerBlock(skill, root); err != nil { + fmt.Fprintf(os.Stderr, "warning: could not clean the legacy OpenCode rules block: %v\n", err) + } return InstalledTarget{TargetName: t.Name(), Method: t.Method(), Path: link, Version: skill.Version, InstalledAt: time.Now().UTC()}, nil } @@ -69,19 +103,22 @@ func (t opencodeTarget) Uninstall() error { // Resolve the target root up front so failures (for example, an // unavailable home directory) are reported to the caller instead of // being silently ignored while iterating over the catalog. - if _, err := t.TargetPath(); err != nil { + root, err := t.TargetPath() + if err != nil { return err } for _, s := range Catalog { - link, err := t.skillLink(s) - if err != nil { - return err - } + link := filepath.Join(root, s.Name) if info, err := os.Lstat(link); err == nil && (info.Mode()&os.ModeSymlink != 0 || info.IsDir() || info.Mode().IsRegular()) { if err := os.RemoveAll(link); err != nil { return fmt.Errorf("remove %s: %w", link, err) } } + // Machines that never ran an install in between still carry the + // legacy block; uninstall has to clear it too. + if err := t.removeLegacyMarkerBlock(s, root); err != nil { + return err + } } return nil } @@ -120,3 +157,53 @@ func (t opencodeTarget) Status() (bool, string, error) { } return true, filepath.Base(resolved), nil } + +// removeLegacyMarkerBlock strips this skill's marker block from +// ~/.config/opencode/AGENTS.md, the rules file older af binaries wrote into. +// +// That file belongs to the user — OpenCode reads it, and af never created it +// on its own — so the rules are deliberately stricter than the Codex +// equivalent: a file holding no block of ours is not opened for writing at +// all (its bytes and mtime stay exactly as the user left them), and the file +// is deleted only when removing our block is what emptied it. Other tools' +// blocks and any user prose are preserved. A missing file is a no-op; every +// other failure is returned, and the two callers weigh it differently — +// Uninstall propagates it, Install warns (see there). +// +// Every filesystem call goes through the package's reconcile* seams so each +// failure branch below is reachable from a test. +func (t opencodeTarget) removeLegacyMarkerBlock(skill Skill, root string) error { + path := t.legacyRulesPath(root) + data, err := reconcileReadFile(path) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return fmt.Errorf("read legacy OpenCode rules file %s: %w", path, err) + } + if _, ours := findMarkerBlock(string(data), skill); !ours { + return nil // nothing of ours in there; leave the user's file alone + } + + cleaned := strings.TrimRight(stripMarkerBlock(string(data), skill), "\n") + if strings.TrimSpace(cleaned) == "" { + // Our block was the only thing in it, so the file was ours alone. + if err := reconcileRemove(path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove %s: %w", path, err) + } + return nil + } + + perm := os.FileMode(0o644) + if info, err := os.Stat(path); err == nil { + perm = info.Mode().Perm() + } + tmp := path + ".af-tmp" + if err := reconcileWriteFile(tmp, []byte(cleaned+"\n"), perm); err != nil { + return fmt.Errorf("write %s: %w", tmp, err) + } + if err := reconcileRename(tmp, path); err != nil { + return fmt.Errorf("rename into %s: %w", path, err) + } + return nil +} diff --git a/control-plane/internal/skillkit/target_opencode_cleanup_test.go b/control-plane/internal/skillkit/target_opencode_cleanup_test.go new file mode 100644 index 000000000..f367ad281 --- /dev/null +++ b/control-plane/internal/skillkit/target_opencode_cleanup_test.go @@ -0,0 +1,346 @@ +package skillkit + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// opencodeLegacyHome prepares an isolated home with ~/.config/opencode present +// and returns the home plus the legacy rules file path inside it. +func opencodeLegacyHome(t *testing.T) (string, string) { + t.Helper() + home := withTempHome(t) + dir := filepath.Join(home, ".config", "opencode") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("mkdir .config/opencode: %v", err) + } + return home, filepath.Join(dir, "AGENTS.md") +} + +// seedCurrentDir returns a canonical current/ directory an install can link at. +func seedCurrentDir(t *testing.T) string { + t.Helper() + current := filepath.Join(t.TempDir(), "current") + if err := os.MkdirAll(current, 0o755); err != nil { + t.Fatalf("mkdir current: %v", err) + } + return current +} + +// foreignBlock is another tool's marker block: same file, different owner. +const foreignBlock = "\nplandb rules\n" + +// Contract (a) + (e): installing the native skill strips this skill's legacy +// marker block from ~/.config/opencode/AGENTS.md while leaving the user's prose +// on both sides of it — and another tool's block — untouched. +func TestOpenCodeInstallStripsLegacyMarkerBlockAndKeepsForeignContent(t *testing.T) { + _, legacy := opencodeLegacyHome(t) + content := "# my own opencode notes\n\n" + + renderPointerBlock(Catalog[0], "/gone/canonical/current") + "\n\n" + + foreignBlock + "\n\nnotes that come after the block\n" + if err := os.WriteFile(legacy, []byte(content), 0o644); err != nil { + t.Fatalf("seed legacy rules file: %v", err) + } + + if _, err := (opencodeTarget{}).Install(Catalog[0], seedCurrentDir(t)); err != nil { + t.Fatalf("Install: %v", err) + } + + data, err := os.ReadFile(legacy) + if err != nil { + t.Fatalf("read legacy rules file: %v", err) + } + got := string(data) + if strings.Contains(got, markerStartPattern(Catalog[0])) { + t.Fatalf("legacy marker block survived the install:\n%s", got) + } + for _, keep := range []string{"# my own opencode notes", foreignBlock, "notes that come after the block"} { + if !strings.Contains(got, keep) { + t.Fatalf("migration destroyed content it does not own (%q missing):\n%s", keep, got) + } + } +} + +// Contract (c): a rules file that held nothing but our block was ours alone, so +// it is deleted rather than left behind as an empty file OpenCode keeps reading. +func TestOpenCodeInstallDeletesLegacyRulesFileItOwnedAlone(t *testing.T) { + for _, tc := range []struct { + name string + content string + }{ + {name: "block only", content: renderPointerBlock(Catalog[0], "/gone/current") + "\n"}, + {name: "block and whitespace", content: "\n \n" + renderPointerBlock(Catalog[0], "/gone/current") + "\n \n\t\n"}, + } { + t.Run(tc.name, func(t *testing.T) { + _, legacy := opencodeLegacyHome(t) + if err := os.WriteFile(legacy, []byte(tc.content), 0o644); err != nil { + t.Fatalf("seed legacy rules file: %v", err) + } + if _, err := (opencodeTarget{}).Install(Catalog[0], seedCurrentDir(t)); err != nil { + t.Fatalf("Install: %v", err) + } + if _, err := os.Lstat(legacy); !os.IsNotExist(err) { + data, _ := os.ReadFile(legacy) + t.Fatalf("legacy rules file should be removed, lstat err=%v content=%q", err, data) + } + }) + } +} + +// Contract (d) + (e): ~/.config/opencode/AGENTS.md is the user's own file. When +// it carries no block of ours, neither install nor uninstall may touch it — +// same bytes, same modification time, whitespace-only content included. +func TestOpenCodeLeavesARulesFileWithoutOurBlockUntouched(t *testing.T) { + for _, tc := range []struct { + name string + content string + }{ + {name: "user prose", content: "# my rules\n\nbe concise\n"}, + {name: "foreign block only", content: foreignBlock + "\n"}, + {name: "whitespace only", content: "\n \n\t\n"}, + {name: "empty", content: ""}, + } { + t.Run(tc.name, func(t *testing.T) { + _, legacy := opencodeLegacyHome(t) + if err := os.WriteFile(legacy, []byte(tc.content), 0o644); err != nil { + t.Fatalf("seed legacy rules file: %v", err) + } + // Backdate so a rewrite is visible even at coarse mtime resolution. + stamp := time.Date(2020, time.March, 4, 5, 6, 7, 0, time.UTC) + if err := os.Chtimes(legacy, stamp, stamp); err != nil { + t.Fatalf("chtimes: %v", err) + } + + target := opencodeTarget{} + if _, err := target.Install(Catalog[0], seedCurrentDir(t)); err != nil { + t.Fatalf("Install: %v", err) + } + assertRulesFileUnchanged(t, legacy, tc.content, stamp, "install") + + if err := target.Uninstall(); err != nil { + t.Fatalf("Uninstall: %v", err) + } + assertRulesFileUnchanged(t, legacy, tc.content, stamp, "uninstall") + }) + } +} + +func assertRulesFileUnchanged(t *testing.T, path, want string, stamp time.Time, stage string) { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("%s removed the user's rules file: %v", stage, err) + } + if string(data) != want { + t.Fatalf("%s rewrote the user's rules file:\ngot: %q\nwant: %q", stage, data, want) + } + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat after %s: %v", stage, err) + } + if !info.ModTime().Equal(stamp) { + t.Fatalf("%s opened the user's rules file for writing: mtime %s, want %s", + stage, info.ModTime().UTC(), stamp) + } +} + +// Contract (b) + (e): uninstall removes every catalog skill's link and finishes +// the migration for machines that never ran an install in between, keeping the +// user's prose and other tools' blocks. +func TestOpenCodeUninstallRemovesLinksAndLegacyBlocks(t *testing.T) { + home, legacy := opencodeLegacyHome(t) + var content strings.Builder + content.WriteString("user prose\n\n") + for _, s := range Catalog { + content.WriteString(renderPointerBlock(s, "/gone/current")) + content.WriteString("\n\n") + } + content.WriteString(foreignBlock + "\n") + if err := os.WriteFile(legacy, []byte(content.String()), 0o644); err != nil { + t.Fatalf("seed legacy rules file: %v", err) + } + + target := opencodeTarget{} + root, err := target.TargetPath() + if err != nil { + t.Fatalf("TargetPath: %v", err) + } + if err := os.MkdirAll(root, 0o755); err != nil { + t.Fatalf("mkdir skills root: %v", err) + } + for _, s := range Catalog { + if err := os.Symlink(filepath.Join(home, "gone"), filepath.Join(root, s.Name)); err != nil { + t.Fatalf("seed link for %s: %v", s.Name, err) + } + } + + if err := target.Uninstall(); err != nil { + t.Fatalf("Uninstall: %v", err) + } + for _, s := range Catalog { + if _, err := os.Lstat(filepath.Join(root, s.Name)); !os.IsNotExist(err) { + t.Fatalf("link for %s remains: %v", s.Name, err) + } + } + data, err := os.ReadFile(legacy) + if err != nil { + t.Fatalf("read legacy rules file: %v", err) + } + if strings.Contains(string(data), "agentfield-skill:") { + t.Fatalf("legacy blocks remain after uninstall:\n%s", data) + } + if !strings.Contains(string(data), "user prose") || !strings.Contains(string(data), foreignBlock) { + t.Fatalf("uninstall destroyed content it does not own:\n%s", data) + } + // Uninstalling twice is a no-op, not an error. + if err := target.Uninstall(); err != nil { + t.Fatalf("second Uninstall: %v", err) + } +} + +// Contract (f): with no legacy rules file on disk, install and uninstall both +// succeed and neither conjures the file into existence. +func TestOpenCodeCleanupIsANoOpWithoutALegacyRulesFile(t *testing.T) { + _, legacy := opencodeLegacyHome(t) + target := opencodeTarget{} + + if _, err := target.Install(Catalog[0], seedCurrentDir(t)); err != nil { + t.Fatalf("Install: %v", err) + } + if _, err := os.Lstat(legacy); !os.IsNotExist(err) { + t.Fatalf("install created a legacy rules file: %v", err) + } + if err := target.Uninstall(); err != nil { + t.Fatalf("Uninstall: %v", err) + } + if _, err := os.Lstat(legacy); !os.IsNotExist(err) { + t.Fatalf("uninstall created a legacy rules file: %v", err) + } +} + +// Contract (g): uninstall reports a legacy rules file it cannot read, rather +// than silently leaving the block behind — there, stripping the block is the +// entire point of the call. +func TestOpenCodeUninstallReportsAnUnreadableLegacyRulesFile(t *testing.T) { + _, legacy := opencodeLegacyHome(t) + // A directory where the rules file belongs: readable path, unreadable file. + if err := os.MkdirAll(legacy, 0o755); err != nil { + t.Fatalf("mkdir over legacy rules path: %v", err) + } + + err := (opencodeTarget{}).Uninstall() + if err == nil { + t.Fatal("Uninstall should report a legacy rules file it cannot read") + } + if !strings.Contains(err.Error(), "AGENTS.md") { + t.Fatalf("error should name the file it could not read: %v", err) + } +} + +// Contract (h): the cleanup is a migration courtesy, not part of making the +// skill work. A legacy rules file af cannot clean up must not fail an install +// whose symlink is already on disk: the caller records nothing for a failed +// target, so failing here would report OpenCode as not installed while it is +// live, and every later `af skill install` would exit non-zero over a stale +// block OpenCode never reads. +func TestOpenCodeInstallSurvivesALegacyRulesFileItCannotClean(t *testing.T) { + home := withTempHome(t) + legacy := filepath.Join(home, ".config", "opencode", "AGENTS.md") + // A directory where the rules file belongs: readable path, unreadable file. + if err := os.MkdirAll(legacy, 0o755); err != nil { + t.Fatalf("mkdir over legacy rules path: %v", err) + } + + report, err := Install(InstallOptions{SkillName: Catalog[0].Name, Targets: []string{"opencode"}}) + if err != nil { + t.Fatalf("Install: %v", err) + } + if len(report.TargetsFailed) != 0 { + t.Fatalf("an uncleanable legacy rules file failed the install: %+v", report.TargetsFailed) + } + if len(report.TargetsInstalled) != 1 || report.TargetsInstalled[0].TargetName != "opencode" { + t.Fatalf("opencode was not reported as installed: %+v", report) + } + + link := filepath.Join(home, ".config", "opencode", "skills", Catalog[0].Name) + if _, err := os.Lstat(link); err != nil { + t.Fatalf("native skill link missing: %v", err) + } + // The link is on disk, so state has to agree — otherwise `af skill list` + // and the next install both disagree with reality. + state, err := LoadState() + if err != nil { + t.Fatalf("LoadState: %v", err) + } + recorded, ok := state.Skills[Catalog[0].Name].Targets["opencode"] + if !ok { + t.Fatal("a live OpenCode install was not recorded in state") + } + if recorded.Path != link { + t.Fatalf("recorded path = %q, want the link on disk %q", recorded.Path, link) + } +} + +// Contract (i): every write the cleanup performs is reported when it fails. +// These branches are unreachable through real filesystem permissions on some +// platforms, so they are driven through the package's reconcile* seams — the +// same way the reconciler's own rewrite failures are covered. +func TestOpenCodeUninstallReportsLegacyRewriteFailures(t *testing.T) { + ourBlock := renderPointerBlock(Catalog[0], "/gone/current") + for _, tc := range []struct { + name string + content string + inject func(t *testing.T) + }{ + { + name: "write", + content: "user prose\n\n" + ourBlock + "\n", + inject: func(t *testing.T) { + old := reconcileWriteFile + reconcileWriteFile = func(string, []byte, os.FileMode) error { + return errors.New("forced write failure") + } + t.Cleanup(func() { reconcileWriteFile = old }) + }, + }, + { + name: "rename", + content: "user prose\n\n" + ourBlock + "\n", + inject: func(t *testing.T) { + old := reconcileRename + reconcileRename = func(string, string) error { return errors.New("forced rename failure") } + t.Cleanup(func() { reconcileRename = old }) + }, + }, + { + name: "remove", + content: ourBlock + "\n", + inject: func(t *testing.T) { + old := reconcileRemove + reconcileRemove = func(string) error { return errors.New("forced remove failure") } + t.Cleanup(func() { reconcileRemove = old }) + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + _, legacy := opencodeLegacyHome(t) + if err := os.WriteFile(legacy, []byte(tc.content), 0o644); err != nil { + t.Fatalf("seed legacy rules file: %v", err) + } + tc.inject(t) + + err := (opencodeTarget{}).Uninstall() + if err == nil { + t.Fatalf("Uninstall should report a failed %s of the legacy rules file", tc.name) + } + if !strings.Contains(err.Error(), "AGENTS.md") || + !strings.Contains(err.Error(), "forced "+tc.name+" failure") { + t.Fatalf("error should name the file and the cause: %v", err) + } + }) + } +} diff --git a/control-plane/internal/skillkit/target_opencode_test.go b/control-plane/internal/skillkit/target_opencode_test.go index ea98e5a96..2cc7e0a44 100644 --- a/control-plane/internal/skillkit/target_opencode_test.go +++ b/control-plane/internal/skillkit/target_opencode_test.go @@ -156,6 +156,7 @@ func TestOpenCodeTargetStatusPreservesVersionFromRemovedDirectLink(t *testing.T) } func TestOpenCodeTargetUninstallRemovesCatalogEntries(t *testing.T) { + withTempHome(t) target := opencodeTarget{} root, err := target.TargetPath() if err != nil { diff --git a/control-plane/internal/skillkit/testmain_test.go b/control-plane/internal/skillkit/testmain_test.go index 0fce5f08a..5244a1aca 100644 --- a/control-plane/internal/skillkit/testmain_test.go +++ b/control-plane/internal/skillkit/testmain_test.go @@ -147,6 +147,7 @@ func realHomeSnapshot(t *testing.T) string { filepath.Join(realHomeBeforeIsolation, ".codex", "skills"), filepath.Join(realHomeBeforeIsolation, ".codex", "AGENTS.override.md"), filepath.Join(realHomeBeforeIsolation, ".gemini", "GEMINI.md"), + filepath.Join(realHomeBeforeIsolation, ".config", "opencode", "skills"), filepath.Join(realHomeBeforeIsolation, ".config", "opencode", "AGENTS.md"), filepath.Join(realHomeBeforeIsolation, ".aider.conventions.md"), filepath.Join(realHomeBeforeIsolation, ".aider.conf.yml"), diff --git a/docs/ENVIRONMENT_VARIABLES.md b/docs/ENVIRONMENT_VARIABLES.md index a96c99e77..0a5f85482 100644 --- a/docs/ENVIRONMENT_VARIABLES.md +++ b/docs/ENVIRONMENT_VARIABLES.md @@ -222,3 +222,10 @@ Attribution is sent as `HTTP-Referer` and `X-Title`: - `AGENTFIELD_INFRON_ATTRIBUTION=false`: Disable Infron attribution headers. When the `AGENTFIELD_INFRON_*` vars are unset, these OpenRouter attribution values are used as fallbacks, so a deployment that already declares its identity keeps it after switching gateways: `AGENTFIELD_OPENROUTER_SITE_URL`, `OR_SITE_URL`, `AGENTFIELD_OPENROUTER_APP_NAME`, `OR_APP_NAME`. The opt-out travels with them: when `AGENTFIELD_OPENROUTER_ATTRIBUTION=false`, these values are not inherited and the Infron defaults apply instead. To control Infron attribution specifically, set the `AGENTFIELD_INFRON_*` vars explicitly or disable it with `AGENTFIELD_INFRON_ATTRIBUTION=false`. + +### Harness (SDKs) + +- `AGENTFIELD_HARNESS_DEPTH`: Marks subprocesses running inside an AgentField + harness session. The SDKs set it to `1` for a first-level child and increment + an inherited numeric value for nested sessions. An explicit per-call `env` + value wins over the derived depth. diff --git a/docs/harness-providers.md b/docs/harness-providers.md index 89af372ac..0204761c0 100644 --- a/docs/harness-providers.md +++ b/docs/harness-providers.md @@ -163,8 +163,8 @@ CLI-specific command construction to application code. | Project root | `--dir` | process working directory | `--cwd` plus process working directory | | One-shot machine output | JSON output | stdin + JSON event stream | stdin + JSON event stream | | System prompt | Native prompt option | Native prompt option | Native prompt option | -| Tool allowlist | Native tool flags | Normalized Pi tool names | Normalized OMP tool names | -| Plan / auto permissions | Native permission flags | Read-only tools / no approval flag | Read-only tools / `--auto-approve` | +| Tool allowlist | Ignored today | Normalized Pi tool names | Normalized OMP tool names | +| Plan / auto permissions | Ignored today | Read-only tools / no approval flag | Read-only tools / `--auto-approve` | | Session resume | Native session option | `--session` | `--resume` | | Structured output | Isolated schema file protocol | Same protocol | Same protocol | | Metrics | Sessions, turns, tokens, cost, duration | Same normalized fields | Same normalized fields | @@ -179,6 +179,10 @@ handled consistently: plan mode removes mutating tools, explicit model variants override `#variant`, and provider-reported metrics are normalized into the shared result type. +OpenCode currently receives only the selected model, project directory, and +prompt. Its adapters ignore `tools` and `permission_mode`; they do not translate +either option to native OpenCode flags today. + ## Model selection and reasoning-effort variants Every provider accepts a `model` option on `.harness()` calls. Leaving it unset diff --git a/sdk/go/harness/cli.go b/sdk/go/harness/cli.go index c2ce6936f..f782defbc 100644 --- a/sdk/go/harness/cli.go +++ b/sdk/go/harness/cli.go @@ -106,6 +106,11 @@ func runCLIWithStdin(ctx context.Context, cmd []string, env map[string]string, c } merged[key] = value } + parentDepth, err := strconv.Atoi(merged["AGENTFIELD_HARNESS_DEPTH"]) + if err != nil || parentDepth < 0 { + parentDepth = 0 + } + merged["AGENTFIELD_HARNESS_DEPTH"] = strconv.Itoa(parentDepth + 1) for k, v := range env { if v == "" { delete(merged, k) diff --git a/sdk/go/harness/coverage_helpers_test.go b/sdk/go/harness/coverage_helpers_test.go index fcc677955..709657a27 100644 --- a/sdk/go/harness/coverage_helpers_test.go +++ b/sdk/go/harness/coverage_helpers_test.go @@ -72,6 +72,28 @@ printf '%s\n' "KEEP_ME=$KEEP_ME" assert.Contains(t, result.Stdout, "KEEP_ME=set") }) + t.Run("sets and increments harness depth while caller env wins", func(t *testing.T) { + dir := t.TempDir() + script := writeTestScript(t, dir, "print-depth", "#!/bin/sh\nprintf '%s' \"$AGENTFIELD_HARNESS_DEPTH\"\n") + + for _, test := range []struct { + name, parent string + env map[string]string + want string + }{ + {name: "first level", want: "1"}, + {name: "nested", parent: "4", want: "5"}, + {name: "caller override", parent: "4", env: map[string]string{"AGENTFIELD_HARNESS_DEPTH": "99"}, want: "99"}, + } { + t.Run(test.name, func(t *testing.T) { + t.Setenv("AGENTFIELD_HARNESS_DEPTH", test.parent) + result, err := RunCLI(context.Background(), []string{script}, test.env, "", 0) + require.NoError(t, err) + assert.Equal(t, test.want, result.Stdout) + }) + } + }) + t.Run("context cancellation returns a killed-process result with partial stdout", func(t *testing.T) { dir := t.TempDir() // Flush the line, give the reader a beat, then stall so the kill lands diff --git a/sdk/python/agentfield/harness/_cli.py b/sdk/python/agentfield/harness/_cli.py index 9b03197d3..35d6cb919 100644 --- a/sdk/python/agentfield/harness/_cli.py +++ b/sdk/python/agentfield/harness/_cli.py @@ -288,6 +288,11 @@ async def run_cli( the final JSONL result/usage events remain parseable. """ merged_env = {**os.environ} + try: + parent_depth = int(merged_env.get("AGENTFIELD_HARNESS_DEPTH", "0")) + except ValueError: + parent_depth = 0 + merged_env["AGENTFIELD_HARNESS_DEPTH"] = str(max(parent_depth, 0) + 1) if env: merged_env.update(env) apply_subprocess_env(merged_env) diff --git a/sdk/python/tests/test_run_cli_env.py b/sdk/python/tests/test_run_cli_env.py index 0dbffbd3a..d07bfbb20 100644 --- a/sdk/python/tests/test_run_cli_env.py +++ b/sdk/python/tests/test_run_cli_env.py @@ -87,6 +87,36 @@ async def test_run_cli_works_without_explicit_env(monkeypatch): assert stdout.strip() == "still_there" +@pytest.mark.asyncio +@pytest.mark.parametrize(("parent_depth", "expected"), [(None, "1"), ("4", "5")]) +async def test_run_cli_sets_harness_depth(monkeypatch, parent_depth, expected): + if parent_depth is None: + monkeypatch.delenv("AGENTFIELD_HARNESS_DEPTH", raising=False) + else: + monkeypatch.setenv("AGENTFIELD_HARNESS_DEPTH", parent_depth) + + stdout, _stderr, returncode = await run_cli( + ["bash", "-c", "echo $AGENTFIELD_HARNESS_DEPTH"], timeout=10.0 + ) + + assert returncode == 0 + assert stdout.strip() == expected + + +@pytest.mark.asyncio +async def test_run_cli_caller_harness_depth_wins(monkeypatch): + monkeypatch.setenv("AGENTFIELD_HARNESS_DEPTH", "4") + + stdout, _stderr, returncode = await run_cli( + ["bash", "-c", "echo $AGENTFIELD_HARNESS_DEPTH"], + env={"AGENTFIELD_HARNESS_DEPTH": "99"}, + timeout=10.0, + ) + + assert returncode == 0 + assert stdout.strip() == "99" + + def test_run_cli_merges_openrouter_attribution_defaults(monkeypatch): monkeypatch.delenv("AGENTFIELD_OPENROUTER_SITE_URL", raising=False) monkeypatch.delenv("AGENTFIELD_OPENROUTER_APP_NAME", raising=False) diff --git a/sdk/typescript/src/harness/cli.ts b/sdk/typescript/src/harness/cli.ts index 2dc797f82..495116516 100644 --- a/sdk/typescript/src/harness/cli.ts +++ b/sdk/typescript/src/harness/cli.ts @@ -39,7 +39,15 @@ export function runCli( ): Promise { return new Promise((resolve, reject) => { const [bin, ...args] = cmd; - const env = { ...process.env, ...options?.env }; + const parsedParentDepth = Number.parseInt(process.env.AGENTFIELD_HARNESS_DEPTH ?? '', 10); + const parentDepth = Number.isFinite(parsedParentDepth) && parsedParentDepth >= 0 + ? parsedParentDepth + : 0; + const env = { + ...process.env, + AGENTFIELD_HARNESS_DEPTH: String(parentDepth + 1), + ...options?.env + }; applyOpenRouterAttributionEnv(env); const hasInput = options?.inputText !== undefined; // 'ignore' on stdin gives the child an immediate EOF instead of an open diff --git a/sdk/typescript/tests/harness_cli.test.ts b/sdk/typescript/tests/harness_cli.test.ts index 7988338d3..eef045d53 100644 --- a/sdk/typescript/tests/harness_cli.test.ts +++ b/sdk/typescript/tests/harness_cli.test.ts @@ -68,6 +68,28 @@ describe('harness cli utilities', () => { }); }); + it.each([ + [undefined, undefined, '1'], + ['4', undefined, '5'], + ['4', '99', '99'] + ])('sets harness depth from parent %s and caller %s', async (parent, caller, expected) => { + if (parent === undefined) delete process.env.AGENTFIELD_HARNESS_DEPTH; + else process.env.AGENTFIELD_HARNESS_DEPTH = parent; + const proc = createProcess(); + spawnMock.mockReturnValueOnce(proc as unknown as ReturnType); + + const pending = runCli(['node'], caller === undefined + ? undefined + : { env: { AGENTFIELD_HARNESS_DEPTH: caller } }); + + expect(spawnMock).toHaveBeenCalledWith('node', [], expect.objectContaining({ + env: expect.objectContaining({ AGENTFIELD_HARNESS_DEPTH: expected }) + })); + proc.emit('close', 0); + await pending; + delete process.env.AGENTFIELD_HARNESS_DEPTH; + }); + it('reports a signal death as a negative exit code', async () => { const proc = createProcess(); spawnMock.mockReturnValueOnce(proc as unknown as ReturnType); diff --git a/skills/agentfield-use/SKILL.md b/skills/agentfield-use/SKILL.md index b28eec25c..ae01121b4 100644 --- a/skills/agentfield-use/SKILL.md +++ b/skills/agentfield-use/SKILL.md @@ -6,6 +6,9 @@ description: "Whenever you have a discrete task to perform — one the user dele # AgentField subharnesses — offload work instead of doing it inline +If `AGENTFIELD_HARNESS_DEPTH` is set, you are running inside an AgentField +harness session: do not dispatch to AgentField agents unless explicitly asked. + 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`. diff --git a/skills/agentfield/SKILL.md b/skills/agentfield/SKILL.md index 897ff985f..dad3e7c9b 100644 --- a/skills/agentfield/SKILL.md +++ b/skills/agentfield/SKILL.md @@ -7,6 +7,9 @@ aliases: [agentfield-multi-reasoner-builder] # AgentField +If `AGENTFIELD_HARNESS_DEPTH` is set, you are running inside an AgentField +harness session: do not dispatch to AgentField agents unless explicitly asked. + You are a **systems architect**. Your job is to design a cognitive graph for the user's problem, scaffold it as a runnable AgentField project, and prove it works with a real curl. The intelligence is in the composition. Individual LLM calls reason at ~0.3 — a deliberately-shaped graph of ten of them can reach 0.8 on a real problem. Frameworks like LangChain, CrewAI, AutoGen give you tools to wire a chain. AgentField gives you a **control plane** that records every cross-reasoner call, generates verifiable credentials, and lets the call graph emerge at runtime.