Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ Today every `agentctl run` is a one-shot subprocess. v0.6 ships **interactive ch

## v0.7.0 — Make `agentctl run` a first-class scheduler task (Option-B pivot) ✅ SHIPPED 2026-06-25

Delivered across slices 7.1–7.5 (PRs #30–#34): `--input k=v` + `${inputs.foo}` interpolation, `--input KEY=@<path>` / `--input-file`, `--output-file` + `spec.outputSchema`, `--skip-if-output-exists`, Maestro/Airflow/Temporal examples, and `--workspace` durable agent memory via a harness-agnostic MCP server (works on Pi and opencode). Coordinated bumps: `agentctl` v0.7.0, `@agent-controller/runtime` + `@agent-controller/runtime-opencode` 0.7.0; ADL schema unchanged (additive `outputSchema`). See [CHANGELOG.md](CHANGELOG.md) for the full per-slice entry. Deferred (tracked follow-ups): the K8s `outputSchema` round-trip + a v0.7-bundled runtime image, and two Pi-adapter improvements to make `--workspace` first-class on Pi (built-in-tool coexistence; per-run MCP config isolation).
Delivered across slices 7.1–7.5 (PRs #30–#34): `--input k=v` + `${inputs.foo}` interpolation, `--input KEY=@<path>` / `--input-file`, `--output-file` + `spec.outputSchema`, `--skip-if-output-exists`, Maestro/Airflow/Temporal examples, and `--workspace` durable agent memory via a harness-agnostic MCP server (works on Pi and opencode). Coordinated bumps: `agentctl` v0.7.0, `@agent-controller/runtime` + `@agent-controller/runtime-opencode` 0.7.0; ADL schema unchanged (additive `outputSchema`). See [CHANGELOG.md](CHANGELOG.md) for the full per-slice entry. Deferred (tracked follow-ups): the K8s `outputSchema` round-trip + a v0.7-bundled runtime image, and per-run Pi MCP config isolation. Pi built-in tools now coexist with MCP when all four are explicitly declared.

**Direction change recorded 2026-06-15.** The pre-v0.7 plan described agentctl shipping its own in-process workflow engine (`agentctl workflow run`) with declarative `AgentWorkflow` YAML, parallel/conditional/foreach step shapes, etc. After thinking through how this would compose with Maestro / Airflow / Temporal — the orchestrators that actually run production multi-step workloads — we picked **Option B**: agentctl is the AGENT RUNTIME, the external scheduler is the ORCHESTRATOR.

Expand Down
25 changes: 9 additions & 16 deletions cli/cmd/agentctl/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -359,27 +359,20 @@ func newRunCmd() *cobra.Command {
"Use a shared volume + the file-handoff flags (--output-file / " +
"--input KEY=@<path>) for cross-step state under Kubernetes")
}
// Pi-adapter caveat (codex pass 1 of slice 7.5): the Pi
// adapter switches to `noTools: "builtin"` whenever ANY MCP
// server is present, which suppresses the built-in
// read/bash/edit/write tools — Pi's API can't keep declared
// built-ins AND allow unknown MCP tool names at the same
// time. So injecting the workspace server would silently
// drop a Pi agent's declared built-in tools. Warn rather
// than fail (the agent may not need those built-ins). The
// opencode and claude adapters grant each declared tool
// independently (claude via the SDK's `allowedTools`) and
// are unaffected.
// Pi can preserve its default built-ins alongside dynamically
// registered MCP tools only when all four are declared together.
// A partial built-in list is suppressed, so warn before injecting
// the workspace MCP server. Opencode and Claude can grant each
// declared tool independently and are unaffected.
isPiAdapter := spec.Runtime.Type != "local-opencode" && spec.Runtime.Type != "local-codex" && spec.Runtime.Type != "local-claude"
if isPiAdapter {
builtins := declaredBuiltinTools(&spec)
if len(builtins) > 0 {
if len(builtins) > 0 && !allPiBuiltinToolsDeclared(builtins) {
fmt.Fprintf(cmd.ErrOrStderr(),
"[warning] --workspace adds an MCP server, and the Pi adapter "+
"disables built-in tools when any MCP server is present, so this "+
"run will lose its declared built-in tool(s): %s. Use "+
"runtime.type: local-opencode (unaffected), or drop --workspace "+
"and hand off via files (--output-file / --input KEY=@<path>).\n",
"requires all four Pi built-ins together. This run will lose its "+
"partial built-in selection: %s. Declare read, bash, edit, and write "+
"together, use runtime.type: local-opencode, or drop --workspace.\n",
strings.Join(builtins, ", "))
}
}
Expand Down
19 changes: 16 additions & 3 deletions cli/cmd/agentctl/workspace_mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,9 @@ import (
const workspaceMCPServerName = "agentctl-workspace"

// declaredBuiltinTools returns the names of Pi-builtin tools (bash, read,
// edit, write) the spec declares. Used to warn that --workspace's injected
// MCP server will cause the Pi adapter to suppress these (see the caveat
// in newRunCmd). Returns nil when none are declared.
// edit, write) the spec declares. Used to warn when --workspace's injected
// MCP server meets a partial built-in selection. Returns nil when none are
// declared.
func declaredBuiltinTools(spec *adl.CompiledSpec) []string {
var builtins []string
for _, tr := range spec.Tools {
Expand All @@ -43,6 +43,19 @@ func declaredBuiltinTools(spec *adl.CompiledSpec) []string {
return builtins
}

func allPiBuiltinToolsDeclared(tools []string) bool {
declared := make(map[string]bool, len(tools))
for _, name := range tools {
declared[name] = true
}
for _, name := range []string{"read", "bash", "edit", "write"} {
if !declared[name] {
return false
}
}
return true
}

// injectWorkspaceMCPServer adds the workspace memory MCP server to
// spec.MCPServers (slice 7.5). The injected entry runs agentctl itself
// (`<self> __workspace-mcp --workspace <absdir>`) over stdio, so the four
Expand Down
17 changes: 16 additions & 1 deletion cli/cmd/agentctl/workspace_mcp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,21 @@ func TestDeclaredBuiltinTools(t *testing.T) {
}
}

func TestAllPiBuiltinToolsDeclared(t *testing.T) {
if allPiBuiltinToolsDeclared(nil) != false {
t.Fatal("nil input must not enable MCP coexistence")
}
if allPiBuiltinToolsDeclared([]string{}) != false {
t.Fatal("empty input must not enable MCP coexistence")
}
if allPiBuiltinToolsDeclared([]string{"read", "bash", "edit", "write"}) != true {
t.Fatal("all four Pi built-ins should enable MCP coexistence")
}
if allPiBuiltinToolsDeclared([]string{"read", "bash", "edit"}) != false {
t.Fatal("a partial Pi built-in list must retain the MCP warning")
}
}

// minimalClaudeAgentSpecWithBash declares a builtin tool under
// runtime.type: local-claude, so declaredBuiltinTools(&spec) is non-empty
// and the Pi-only "--workspace will drop your built-ins" warning would fire
Expand Down Expand Up @@ -326,7 +341,7 @@ func TestRunWorkspaceNoPiWarningForClaudeAdapter(t *testing.T) {
if err == nil || !strings.Contains(err.Error(), "claude runtime not found") {
t.Fatalf("expected a deterministic 'claude runtime not found' error, got: %v", err)
}
if strings.Contains(stderr.String(), "will lose its declared built-in tool") {
if strings.Contains(stderr.String(), "[warning] --workspace adds an MCP server, and the Pi adapter") {
t.Errorf("claude adapter must not get the Pi-only built-ins warning; stderr: %q", stderr.String())
}
}
Expand Down
8 changes: 4 additions & 4 deletions docs/architecture/harness-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,10 @@ This document is the artifact that justifies the claim "ADL is harness-agnostic.

| Feature | Pi | opencode | codex | claude | hermes-agent |
|---|---|---|---|---|---|
| Pi built-in `bash` | ⚠️ — ✅ on its own (since v0.1.9); **deactivated when `spec.mcpServers[]` is non-empty** because the adapter switches to `noTools: "builtin"` to suppress all built-in tools and let MCP tools enter the registry. Combining built-ins with MCP servers in the same spec is currently unsupported; v0.2+ work. | ✅ — maps to opencode permission `bash: allow`. Coexists with `spec.mcpServers[]` (no exclusivity restriction on the opencode side) | ✅ — the codex sandbox (`-s workspace-write`) provides shell/read/write access natively; declaring `bash` in `spec.tools` is accepted without error (the ADL built-in flag is recognized). The codex sandbox controls what the model can do regardless of the ADL tools list. | ✅ — maps to the SDK's `Bash` tool via `Options.allowedTools`. Coexists with `spec.mcpServers[]` (no exclusivity restriction, unlike the Pi adapter) | 🚧 |
| Pi built-in `read` | ⚠️ — same MCP-incompatibility caveat as `bash` | ✅ — maps to opencode permission `read: allow` | ✅ — same as `bash`: accepted without error; the codex sandbox provides the capability directly. | ✅ — maps to the SDK's `Read` tool via `Options.allowedTools` | 🚧 |
| Pi built-in `edit` | ⚠️ — same MCP-incompatibility caveat as `bash` | ✅ — maps to opencode permission `edit: allow` | ✅ — same as `bash`. | ✅ — maps to the SDK's `Edit` tool via `Options.allowedTools` | 🚧 |
| Pi built-in `write` | ⚠️ — same MCP-incompatibility caveat as `bash` | ⚠️ — opencode has no separate `write` permission; `write` collapses onto `edit: allow` (which already gates file creation/overwrite). Declaring both `edit` and `write` is idempotent | ✅ — same as `bash`. | ✅ — maps to the SDK's `Write` tool via `Options.allowedTools` | 🚧 |
| Pi built-in `bash` | ⚠️ — ✅ on its own (since v0.1.9). With `spec.mcpServers[]`, all four Pi built-ins (`read`, `bash`, `edit`, `write`) must be explicitly declared together; the adapter then leaves Pi's default built-ins enabled while MCP tools register dynamically. Declaring none keeps all built-ins disabled; partial built-in selection with MCP remains unsupported. | ✅ — maps to opencode permission `bash: allow`. Coexists with `spec.mcpServers[]` (no exclusivity restriction on the opencode side) | ✅ — the codex sandbox (`-s workspace-write`) provides shell/read/write access natively; declaring `bash` in `spec.tools` is accepted without error (the ADL built-in flag is recognized). The codex sandbox controls what the model can do regardless of the ADL tools list. | ✅ — maps to the SDK's `Bash` tool via `Options.allowedTools`. Coexists with `spec.mcpServers[]` | 🚧 |
| Pi built-in `read` | ⚠️ — same all-four-or-none MCP rule as `bash` | ✅ — maps to opencode permission `read: allow` | ✅ — same as `bash`: accepted without error; the codex sandbox provides the capability directly. | ✅ — maps to the SDK's `Read` tool via `Options.allowedTools` | 🚧 |
| Pi built-in `edit` | ⚠️ — same all-four-or-none MCP rule as `bash` | ✅ — maps to opencode permission `edit: allow` | ✅ — same as `bash`. | ✅ — maps to the SDK's `Edit` tool via `Options.allowedTools` | 🚧 |
| Pi built-in `write` | ⚠️ — same all-four-or-none MCP rule as `bash` | ⚠️ — opencode has no separate `write` permission; `write` collapses onto `edit: allow` (which already gates file creation/overwrite). Declaring both `edit` and `write` is idempotent | ✅ — same as `bash`. | ✅ — maps to the SDK's `Write` tool via `Options.allowedTools` | 🚧 |
| Custom tool (registry manifest with Pi-extension entrypoint) | ✅ | ❌ — rejected at **compile time** in `cli/internal/adl/compiler.go::checkOpencodeIncompatibilities` as of v0.3.4 (was adapter-startup in `buildOpencodeConfig`; the runtime check stays as defense-in-depth). Error names every offending tool | ❌ — **rejected at adapter startup** by `assertCodexCompatible`. Error names every offending tool. | ❌ — rejected at **compile time** in `cli/internal/adl/compiler.go::checkClaudeIncompatibilities`. Adapter startup's `assertClaudeCompatible` retains the same check as defense-in-depth. Error names every offending tool. | 🚧 |
| `config` on built-in tools (e.g. bash allowlist) | ❌ — rejected at compile time as of v0.1.11. Pi built-ins (`bash`/`read`/`edit`/`write`) have no per-tool config plumbing. Use [`@gotgenes/pi-permission-system`](https://www.npmjs.com/package/@gotgenes/pi-permission-system) as a `spec.extensions[].source: npm:` entry instead — see [`examples/bash-allowlist.yaml`](../../examples/bash-allowlist.yaml). The rejection lives in `cli/internal/adl/compiler.go::Compile`, so `agentctl compile` catches it. **NOTE:** `agentctl validate` (JSON Schema only) does NOT catch it today — CI gates that stop at `validate` will still pass the silently-broken spec. Moving the check into Schema or into a post-validate hook is a v0.3 follow-up. | ❌ — rejected at compile time (same canonical check as Pi). The opencode adapter retains a runtime defense-in-depth check that fires if a hand-crafted CompiledSpec bypasses the compiler. | ❌ — rejected at compile time (same canonical check). Adapter startup retains a defense-in-depth check. | ❌ — rejected at compile time (same canonical, runtime-agnostic check as Pi/opencode/codex — this check runs before the runtime-specific dispatch table). | 🚧 |

Expand Down
2 changes: 1 addition & 1 deletion docs/architecture/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ flowchart LR
| Pi-format extensions (audit-log, custom tools) | Pi adapter (`local` / `local-pi`) | Pi extension modules don't run inside opencode |
| Session resume (`--resume`) | Pi adapter | opencode resume not yet wired |
| `bash` allowlist via tool config | Pi adapter (v0.1.11+) | opencode rejects per-tool config |
| MCP + built-in tools coexisting in the same spec | opencode adapter | Pi adapter currently deactivates built-ins when MCP is non-empty |
| MCP + independently selectable built-in tools | opencode adapter | Pi supports MCP plus built-ins only when all four (`read`, `bash`, `edit`, `write`) are explicitly declared together |
| Native opencode MCP / subagent support without Pi indirection | opencode adapter | Native cfg.mcp + cfg.agent[subagent] |
| Cancellation event (`reason: cancelled`) on SIGINT | opencode adapter | Pi adapter currently surfaces SIGINT as `error` |

Expand Down
2 changes: 1 addition & 1 deletion docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -827,7 +827,7 @@ ls /tmp/ws-demo # .agentctl-workspace.db (+ notes.md if the agent jou
sqlite3 /tmp/ws-demo/.agentctl-workspace.db 'SELECT key, value FROM kv;'
```

The memory tools ride on `spec.mcpServers`, so the same workspace works on Pi and opencode. Hermetic negative checks: `--workspace` with a `kubernetes` binding errors (host-local DB); `--workspace ""` errors; on Pi, combining `--workspace` with declared built-in tools prints a warning (Pi suppresses built-ins when any MCP server is present).
The memory tools ride on `spec.mcpServers`, so the same workspace works on Pi and opencode. Hermetic negative checks: `--workspace` with a `kubernetes` binding errors (host-local DB); `--workspace ""` errors. On Pi, built-ins remain disabled with MCP unless all four (`read`, `bash`, `edit`, `write`) are explicitly declared together.

### 10.6 Scheduler examples

Expand Down
72 changes: 71 additions & 1 deletion runtime/src/adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,8 @@ const captured = {
modelReturnUndefined: false,
// Records calls to SessionManager static factories.
sessionManagerCalls: [] as Array<{ method: string; args: any[] }>,
settingsManagerCreateArgs: [] as unknown[],
settingsOverrides: [] as Array<Record<string, unknown>>,
};

vi.mock("@earendil-works/pi-ai/providers/all", () => ({
Expand Down Expand Up @@ -244,11 +246,22 @@ vi.mock("@earendil-works/pi-coding-agent", () => {
}
}

class SettingsManager {
static create(...args: unknown[]) {
captured.settingsManagerCreateArgs = args;
return {
applyOverrides: (overrides: Record<string, unknown>) => {
captured.settingsOverrides.push(overrides);
},
};
}
}

function getAgentDir() {
return "/mock/agent/dir";
}

return { createAgentSession, DefaultResourceLoader, SessionManager, getAgentDir };
return { createAgentSession, DefaultResourceLoader, SessionManager, SettingsManager, getAgentDir };
});

import { runSession } from "./adapter.js";
Expand All @@ -264,6 +277,8 @@ beforeEach(() => {
loaderErrors.errors = [];
captured.promptResolvers = [];
captured.sessionManagerCalls = [];
captured.settingsManagerCreateArgs = [];
captured.settingsOverrides = [];
// Reset fake filesystem state between tests.
fakeFs.files.clear();
fakeFs.dirs.clear();
Expand Down Expand Up @@ -802,6 +817,21 @@ describe("adapter", () => {
expect(captured.createAgentArgs.noTools).toBeUndefined();
});

it("does not create MCP coexistence settings without MCP servers", async () => {
const spec = fixture();
spec.tools = ["read", "bash", "edit", "write"].map((name) => ({
name,
builtin: true,
}));
const ended = runSession(spec, () => {});
await flushAndShutdown();
await ended;

expect(captured.createAgentArgs.tools).toEqual(["read", "bash", "edit", "write"]);
expect(captured.createAgentArgs.settingsManager).toBeUndefined();
expect(captured.settingsOverrides).toEqual([]);
});

// MCP tool allowlist fix: when mcpServers is non-empty, passing tools: []
// sets allowedToolNames to an empty Set in Pi, which blocks ALL tools from
// _toolRegistry — including MCP tools registered post-session-start by
Expand Down Expand Up @@ -834,6 +864,46 @@ describe("adapter", () => {
expect(captured.createAgentArgs.noTools).toBe("builtin");
});

it("keeps Pi built-ins enabled when an MCP spec explicitly declares all four", async () => {
const spec = mcpFixture();
spec.tools = ["read", "bash", "edit", "write"].map((name) => ({
name,
builtin: true,
}));
const ended = runSession(spec, () => {});
await flushAndShutdown();
await ended;

expect(captured.createAgentArgs.tools).toBeUndefined();
expect(captured.createAgentArgs.noTools).toBeUndefined();
expect(captured.resourceLoaderArgs.settingsManager).toBeUndefined();
expect(captured.createAgentArgs.settingsManager).toBeDefined();
expect(captured.settingsManagerCreateArgs).toEqual([process.cwd(), "/mock/agent/dir"]);
expect(captured.settingsOverrides).toEqual([
{ defaultTools: ["read", "bash", "edit", "write"] },
]);
});

it("keeps custom tools active with all Pi built-ins and MCP", async () => {
const spec = mcpFixture();
spec.tools = [
...["read", "bash", "edit", "write"].map((name) => ({ name, builtin: true })),
{ name: "get_time", entrypoint: "/abs/tools/get_time/entrypoint.ts" },
];
const ended = runSession(spec, () => {});
await flushAndShutdown();
await ended;

expect(captured.createAgentArgs.tools).toBeUndefined();
expect(captured.createAgentArgs.noTools).toBeUndefined();
expect(captured.resourceLoaderArgs.additionalExtensionPaths).toContain(
"/abs/tools/get_time/entrypoint.ts",
);
expect(captured.settingsOverrides).toEqual([
{ defaultTools: ["read", "bash", "edit", "write"] },
]);
});

// Finding 2: unknown model error
it("throws a clear error when getBuiltinModel returns undefined", async () => {
captured.modelReturnUndefined = true;
Expand Down
Loading
Loading