From 17e89231f2e85dbc70131253b2860ae18308a209 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Tue, 4 Aug 2026 20:59:23 +0200 Subject: [PATCH 01/36] docs(design): agent-config-editing planning workspace Planning docs (context, plan, status, research index) plus the accepted research inputs: the RFC, the change-set interface spec, and the runner lifecycle architecture. --- docs/design/agent-config-editing/README.md | 37 + docs/design/agent-config-editing/context.md | 65 + docs/design/agent-config-editing/plan.md | 62 + docs/design/agent-config-editing/research.md | 78 ++ .../research/change-set-interface-codex.md | 444 +++++++ .../agent-config-editing/research/rfc.html | 1063 +++++++++++++++++ .../research/runner-lifecycle-codex.md | 638 ++++++++++ docs/design/agent-config-editing/status.md | 39 + 8 files changed, 2426 insertions(+) create mode 100644 docs/design/agent-config-editing/README.md create mode 100644 docs/design/agent-config-editing/context.md create mode 100644 docs/design/agent-config-editing/plan.md create mode 100644 docs/design/agent-config-editing/research.md create mode 100644 docs/design/agent-config-editing/research/change-set-interface-codex.md create mode 100644 docs/design/agent-config-editing/research/rfc.html create mode 100644 docs/design/agent-config-editing/research/runner-lifecycle-codex.md create mode 100644 docs/design/agent-config-editing/status.md diff --git a/docs/design/agent-config-editing/README.md b/docs/design/agent-config-editing/README.md new file mode 100644 index 0000000000..fd5e9b1db4 --- /dev/null +++ b/docs/design/agent-config-editing/README.md @@ -0,0 +1,37 @@ +# Agent config editing + +An agent in the Agenta playground can edit its own configuration. Today every edit must +resend the full content, which wastes tokens, breaks above ~4.8 KB, and silently replaces +whole lists. This project replaces that with targeted operations, and refactors the runner +so small configuration changes stop forcing a full sandbox rebuild. + +## Reading order + +| File | Answers | +|---|---| +| `context.md` | Why this work exists. Goals, non-goals, user stories. | +| `plan.md` | The execution plan: slices, order, QA gates. | +| `status.md` | Where the work stands right now. Decisions and blockers. | +| `research.md` | What the codebase research found, with file references. | +| `research/rfc.html` | The full RFC: requirements, design questions, decided options. | +| `research/change-set-interface-codex.md` | The change-set interface spec (external design review, accepted as working draft). | +| `research/runner-lifecycle-codex.md` | The runner lifecycle architecture and its migration path. | +| `spikes/engine-spike.md` | Findings from the change-set engine prototype. | +| `spikes/runner-spike.md` | Findings from the runner-side spikes. | + +## Glossary + +- **Configuration**: the JSON object at `parameters.agent` in a revision. It holds the + instructions, the model, the tools, the skills, the MCP servers, the harness, and the + permissions. +- **Revision**: one committed version of the configuration. Revisions are immutable. +- **Harness**: the coding agent that runs inside the sandbox (Pi, Claude Code, or Codex). +- **Runner**: the TypeScript service (`services/runner`) that creates sandboxes, writes + workspace files, opens harness sessions, and executes turns. +- **Sandbox**: the isolated machine (local process or Daytona VM) the harness runs in. +- **Warm session**: a sandbox plus harness session the runner keeps alive between turns. +- **Fingerprint**: today, one checksum over all configuration values. The runner compares + it to decide whether a parked warm session can be reused. +- **Builder tools**: platform tools (commit_revision and others) injected into playground + runs only, never stored in the configuration. +- **Change set / delta**: the payload of a commit: what to change relative to a base. diff --git a/docs/design/agent-config-editing/context.md b/docs/design/agent-config-editing/context.md new file mode 100644 index 0000000000..30dd3d3f35 --- /dev/null +++ b/docs/design/agent-config-editing/context.md @@ -0,0 +1,65 @@ +# Context + +## What happens today + +You build an agent by talking to it in the playground. The agent edits its own +configuration through a platform tool, `commit_revision`. The tool takes a change: +`set` (a partial object, deep-merged) and `remove` (dotted paths to delete). The merge +recurses into objects only. Scalars and lists are replaced whole. + +Four consequences drive this project: + +1. **Every edit is a full rewrite.** The instructions are one string. Skills, tools, and + MCP servers are lists. To fix a typo, the agent resends the whole file. To change one + skill, it resends every skill with every bundled file. +2. **Large payloads fail.** Tool-call arguments above ~4.8 KB arrive truncated on the + Claude harness (issue #5554). A downloaded skill folder cannot be committed at all, + because the agent must retype its full content through the tool call. +3. **Stale writes are silent.** The commit merges onto the latest committed revision. If + someone else moved the head, the write silently overwrites their change. +4. **Any change throws away the warm session.** The runner decides warm reuse with one + checksum over the whole configuration. Change one word, and the next turn pays a full + rebuild: about 12.5 seconds instead of 1.4. A configuration mismatch even deletes the + Daytona sandbox instead of stopping it. + +The agent also cannot read its own configuration before it writes (issue #5186), and the +shipped guidance compensates for missing commit validation by demanding a full live test +run after every change. + +## Goal + +One working stacked PR set that lets an agent: + +- edit one line of its instructions with an anchored text edit (US-1), +- edit one line of one skill without touching the others (US-2), +- install a large downloaded skill by pointing at its folder in the workspace (US-3), +- add or remove one tool by name (US-4), +- read its configuration, in parts, before writing (US-5), +- fail loudly and retry when the base moved, instead of overwriting (US-7), + +and refactors the runner so sessions stay correct and cheap when the configuration +changes (US-8): update in place for most values, rebuild only when the harness kind or +the sandbox provider changes. + +## Non-goals + +- **US-6, run a change without saving it.** Moved out of scope on 4 August. The change-set + format stays compatible so this can come back later. +- **Full approval-screen redesign.** The frontend work is minimal: show the agent's + description on tool cards, and show name, file list, and diff on folder-commit + approvals. +- **Push notifications to running sessions.** Correctness does not need them. Deferred. +- **A CLI in the sandbox.** Closed: it would need credentials inside the sandbox. +- **A configuration file in every workspace.** Closed: shared agents must not expose + internals, and a stale file gives the agent no recovery action. + +## Requirements + +The full numbered list (R1 to R12) is in `research/rfc.html`, section 4. The short form: +edits cost tokens proportional to the change; large content moves by workspace reference; +every target has a stable address with unique names enforced at commit; stale commits +fail loudly; the agent can read its config in parts with a draft flag; everything works +on all three harnesses and both sandboxes; builder tools stay playground-only and +self-targeted; no credential enters the sandbox; no session runs a stale configuration; +the commit validates shape; builder tool calls carry an optional agent-written +description that the frontend shows. diff --git a/docs/design/agent-config-editing/plan.md b/docs/design/agent-config-editing/plan.md new file mode 100644 index 0000000000..a50bffac6f --- /dev/null +++ b/docs/design/agent-config-editing/plan.md @@ -0,0 +1,62 @@ +# Execution plan + +The work runs in three phases: spikes, then vertical slices, then finalization. Each +slice lands as a QA-able increment on its own GitButler lane, stacked in dependency +order. PR bases follow the stack; the bottom lane targets the current release branch, +never main. + +## Team + +- **team-lead** (this session): plans, reviews, integrates, keeps these docs current, + runs external design and code reviews through Codex at the highest reasoning setting. +- **engine-spike** (Opus): change-set engine prototype, then the API-side slices. +- **runner-spike** (Opus): runner-side spikes, then the runner slices. +- **qa** (Sonnet, joins at slice 1): per-slice tests and live QA on the dev stack. + +Spikes run in throwaway worktrees. Slice work lands on GitButler lanes in the main +working directory, one lane per slice, coordinated by the team lead so two agents never +edit one lane at the same time. + +## Phase 1: spikes (running) + +| Spike | Owner | Question | +|---|---|---| +| Engine prototype | engine-spike | Does the ordered-operations engine hold up in code? What did the spec leave undecided? | +| value_from proof | runner-spike | Can the runner confine a workspace path, convert a folder to a skill, and inject the value into the commit call? | +| Tools discovery | runner-spike | Can a live harness discover a changed tool list (MCP list_changed), per harness? | +| Lifecycle characterization | runner-spike | Tests that pin today's fingerprint, teardown, and approval-repark behavior. | + +Exit gate: team lead reviews both spike reports, resolves the implicit decisions they +surface (product calls go to Mahmoud), updates the design docs to final, and runs a +Codex review of the finalized design. + +## Phase 2: vertical slices + +Slices 1 to 4 are API-and-frontend work (engine-spike). Slices 5 to 7 are runner work +(runner-spike). The two tracks run in parallel; they touch disjoint files. + +| Slice | Content | User stories served | +|---|---|---| +| 1 | Change-set engine + commit wrapper: ordered operations, base check atomic with the insert (409 with both ids), commit validation, unique names, strict DTOs, catalog schema. | US-1, US-2, US-4, US-7 | +| 2 | `read_config` tool: self-bound revision read, partial reads, revision id + draft flag in the response, shaped output. | US-5, US-7 retry loop | +| 3 | `value_from` workspace path end to end: runner resolution, folder-to-skill codec, frozen approval content, minimal approval card (name, file list, diff). | US-3 | +| 4 | Optional agent-written `description` on builder tool calls, shown on tool cards. | R12 | +| 5 | Runner safety fixes + applied-state identity (lifecycle migration steps 1 and 2): revision id out of the fingerprint, teardown stops instead of deleting where safe, environment owns applied state, approval-stale-config bug structurally dead. | US-8 | +| 6 | Coordinator extraction + shadow routing (migration steps 3 and 4). Behavior unchanged; the new router runs in shadow and logs disagreements. | US-8 | +| 7 | Lifecycle split + in-place routes (migration steps 5 to 8): workspace refresh with deletions, setModel, Codex mode, session reopen for Claude/Codex tool changes, runtime restart for Pi tool changes, credential refresh so Daytona keys never rebuild. | US-8 | + +QA gates: the qa teammate tests each slice when it lands (unit suites plus live stories +on the dev stack). A regression blocks the slice until fixed. + +## Phase 3: finalization + +Codex code review over the full diff; fix findings. `/write-pr-description` for each +lane PR. Each teammate adds inline PR comments explaining their work. All prose in +simple technical English (ASD-STE100). `/keep-docs-in-sync` for the changed contracts: +the op catalog, the commit endpoint, the runner behavior. Hand the stack to Mahmoud. + +## Task list mapping + +The shared task list mirrors this plan: task 1 = this workspace and the draft PR; +tasks 2-3 = spikes; task 4 = the phase 1 exit gate; tasks 5-11 = slices 1-7; +task 12 = QA; task 13 = finalization. diff --git a/docs/design/agent-config-editing/research.md b/docs/design/agent-config-editing/research.md new file mode 100644 index 0000000000..695507ebcf --- /dev/null +++ b/docs/design/agent-config-editing/research.md @@ -0,0 +1,78 @@ +# Research notes + +The three primary documents live in `research/`. This file is the index plus the code +facts each slice builds on, with file references. All were verified on main, 2026-08-04. + +## Primary documents + +- `research/rfc.html`: requirements, user stories, design questions with decided + options, known defects. This is the product source of truth. +- `research/change-set-interface-codex.md`: the delta interface. Ordered operations, + structured targets, error model, base check, `value_from`, one engine with two + wrappers. Accepted as the working draft. +- `research/runner-lifecycle-codex.md`: the runner architecture. Applied-state identity, + five lifecycles, harness and provider ports, a nine-step migration path, twelve risks. + Accepted as the working draft. + +## Code facts the slices build on + +### Commit path (slices 1, 2) + +- Delta application: `_resolve_revision_delta` merges onto the variant's latest + committed revision, `api/oss/src/core/workflows/service.py:1984-2015`; `_deep_merge` + recurses dicts only, `:2409-2417`. The head fetch and the DAO insert run in separate + transactions (`api/oss/src/dbs/postgres/git/dao.py:1606`), so the base check must move + into one transaction. +- Tool schema: `commit_revision` in + `sdks/python/agenta/sdk/agents/platform/op_catalog.py:688-747, 1102-1112`. Context + bindings strip server-owned fields from the model-visible schema. +- The delta DTO does not forbid unknown keys: `api/oss/src/core/workflows/dtos.py:301`. +- Uniqueness today: skills, none (runner silently drops the duplicate, + `services/runner/src/engines/skills.ts:156-176`); tools, run-time only + (`sdks/python/agenta/sdk/agents/tools/resolver.py:90-135`); MCP servers, none. + Gateway tool `name` is optional (`sdks/python/agenta/sdk/agents/tools/models.py:89`). +- Revision read endpoints for `read_config`: `POST /workflows/revisions/log` binds a + variant id in the body (`api/oss/src/apis/fastapi/workflows/router.py:431-440`); + `$ctx.workflow.is_draft` resolves as a binding token today + (`services/runner/tests/unit/tool-direct.test.ts:314`). Ops cannot bind query params, + only body fields and path params (`services/runner/src/tools/direct.ts:389-390`). + +### Workspace and value_from (slice 3) + +- Files are materialized once per cold acquire by `prepareWorkspace` + (`services/runner/src/engines/sandbox_agent/workspace.ts:50-157`), into the durable + mount (mount happens first on purpose, + `services/runner/src/engines/sandbox_agent/environment.ts:763-766`). +- Skill on-disk format: `composeSkillMd` and `resolveSkillDirs`, + `services/runner/src/engines/skills.ts:118-215`. Known round-trip losses: the + `allow_executable_files` flag, multi-line descriptions, binary files, embed items. +- The runner reads sandbox files on every tool call: `sandboxRelayHost`, + `services/runner/src/tools/relay.ts:255-300`. Argument layering where the resolution + step slots in: `assembleBody`, `services/runner/src/tools/direct.ts:210-247`. +- The mount fails open when it cannot attach (adjacent to issue #5342): + `environment.ts:777-789, 835-845`. + +### Runner lifecycle (slices 5 to 7) + +- The wholesale fingerprint: `configFingerprint`, + `services/runner/src/engines/sandbox_agent/session-identity.ts:199-259`. It includes + the revision id, version, and draft flag (`:250-258`). +- The warm gate and eviction: `services/runner/src/server.ts:675-709`. Re-parking stamps + the incoming request's fingerprint (`:596-603`); the approval-resume path never + compares fingerprints (`:767-870`); this is the stale-config bug. +- Teardown maps `compatibility-mismatch` to delete, not stop: + `services/runner/src/engines/sandbox_agent/teardown.ts:23-37`. The cold path then + still tries to reconnect the deleted sandbox (`environment.ts:662-693`). +- Measured costs (dev stack): warm continuation 1.4 s, stopped-sandbox restart 7.7 s, + full cold 12.5 s + (`docs/design/agent-workflows/projects/warm-daytona-sessions/pr-body.md:21-32`). +- Existing live-apply entry points: `setModel` + (`services/runner/src/engines/sandbox_agent/model.ts:88`), Codex mode + (`codex-mode.ts:21`). MCP servers reach Claude/Codex at session init (`mcp.ts:329`); + Pi tool specs are startup assets (`pi-assets.ts:341`). + +### Adjacent filed issues + +#5554 (truncation, motivates but is not fixed by this project), #5186 (read config), +#5173/#5174/#5407 (third-party tools, separate work), #5342 (mount fail-open), #5397 +(slug editing, separate work). diff --git a/docs/design/agent-config-editing/research/change-set-interface-codex.md b/docs/design/agent-config-editing/research/change-set-interface-codex.md new file mode 100644 index 0000000000..17f53171ff --- /dev/null +++ b/docs/design/agent-config-editing/research/change-set-interface-codex.md @@ -0,0 +1,444 @@ +## Verdict + +Reject the proposed shape. + +Keep `delta` for compatibility, but make the new form an ordered `operations` list. Do not ship independent `edit`, `items`, and `from_files` arrays. They create implicit ordering, mix different semantic roles, and force special nested patch formats. + +Also reject: + +- `upsert`: it conceals whether the model intended creation or replacement. +- `patch`: it is too vague once patches can include merges, text edits, and nested item changes. +- Selector syntax such as `skills[name=release-qa]`: it creates a new escaping grammar that JSON Schema cannot meaningfully validate. + +The current implementation applies `set` and then `remove` against the latest fetched revision [service.py](/home/mahmoud/code/agenta-2/api/oss/src/core/workflows/service.py:1984), with lists replacing wholesale in `_deep_merge` [service.py](/home/mahmoud/code/agenta-2/api/oss/src/core/workflows/service.py:2409). The model documentation confirms how dangerous that is [agenta_builtins.py](/home/mahmoud/code/agenta-2/sdks/python/agenta/sdk/agents/adapters/agenta_builtins.py:249). + +## Field placement by semantic role + +| Field | Role | Owner | Placement | +|---|---|---|---| +| `workflow_variant_id` | Routing | Runner/platform | Revision envelope, hidden from model | +| `base_revision_id` | Protocol precondition | Caller building the change; runner may default legacy calls | Revision envelope beside `delta` | +| `message` | Commit metadata | Model/caller | Revision envelope | +| `delta` | Change data | Model/caller | Revision envelope | +| `value_from` | Content-source declaration | Model | Inside the operation whose value it supplies | +| Materialized source content | Input data | Runner | Internal resolved operation, never model-authored | + +`base_revision_id` and workspace source resolution do not belong inside the delta merely because they support a commit. + +## Concrete counter-proposal + +Model-visible payload: + +```json +{ + "workflow_revision": { + "base_revision_id": "019c...", + "message": "Update release QA instructions and add the PDF skill.", + "delta": { + "operations": [ + { + "operation": "edit_text", + "target": [ + "parameters", + "agent", + "instructions", + "agents_md" + ], + "edits": [ + { + "old_text": "Run the release checks manually.", + "new_text": "Run the release checks with the release-qa skill." + } + ] + }, + { + "operation": "edit_text", + "target": [ + "parameters", + "agent", + { + "field": "skills", + "key": "release-qa" + }, + "body" + ], + "edits": [ + { + "old_text": "Check the API.", + "new_text": "Check the API and the runner." + } + ] + }, + { + "operation": "remove_item", + "target": [ + "parameters", + "agent", + { + "field": "tools", + "key": "send-slack-message" + } + ] + }, + { + "operation": "add_item", + "target": [ + "parameters", + "agent", + "skills" + ], + "value_from": { + "type": "workspace", + "path": "downloaded-skills/pdf-tools" + } + } + ] + } + } +} +``` + +A nested skill file is addressed without inventing a string grammar: + +```json +{ + "operation": "edit_text", + "target": [ + "parameters", + "agent", + { + "field": "skills", + "key": "release-qa" + }, + { + "field": "files", + "key": "scripts/check.py" + }, + "content" + ], + "edits": [ + { + "old_text": "timeout = 30", + "new_text": "timeout = 60" + } + ] +} +``` + +The strict schema should be conceptually: + +```text +Delta = LegacyDelta | OrderedDelta + +LegacyDelta = { + set?: object, + remove?: string[] +} +At least one field required. + +OrderedDelta = { + operations: Operation[1..] +} + +Operation = + SetOperation + | MergeOperation + | RemoveOperation + | EditTextOperation + | AddItemOperation + | ReplaceItemOperation + | RemoveItemOperation +``` + +Every object gets `additionalProperties: false`. The two delta forms are mutually exclusive. + +Operation meanings: + +| Operation | Meaning | +|---|---| +| `set` | Replace the target value exactly | +| `merge` | Deep-merge an object using today’s dict-only recursion | +| `remove` | Remove an object field; missing target is an error | +| `edit_text` | Apply anchored edits to one string | +| `add_item` | Add a new named item; collision is an error | +| `replace_item` | Replace an existing named item; absence is an error | +| `remove_item` | Remove an existing named item; absence is an error | + +`set`, `add_item`, and `replace_item` accept exactly one of `value` or `value_from`. + +Operations run sequentially. A target is evaluated against the result of previous operations. Within one `edit_text`, every anchor is matched against that operation’s starting string. + +## Direct answers + +### 1. Overall shape + +Use an ordered operation list, but do not copy JSON Patch literally. + +JSON Patch’s numeric array indices are inappropriate for named configuration objects, and JSON Pointer does not solve stable list identity. A discriminated operation union gives the model clearer verbs and gives the server an exact failing operation index. + +Keep it inside `delta.operations` because `delta` is already the public concept. Do not introduce `change_set` and `delta` concurrently. + +Do not permit this: + +```json +{ + "set": {}, + "remove": [], + "operations": [] +} +``` + +Legacy and ordered forms should be a schema `oneOf`. This eliminates cross-form ordering questions. New operations execute in array order, then the server validates the complete result and commits once. + +The current catalog already manually defines a closed schema and strips bound fields [op_catalog.py](/home/mahmoud/code/agenta-2/sdks/python/agenta/sdk/agents/platform/op_catalog.py:703). A strict discriminated union fits that mechanism. + +### 2. Addressing + +Do not extend dotted paths with bracket selectors. + +`skills[name=release-qa]` looks simple until names contain quoting characters, file paths contain brackets, or selectors need escaping. JSON Schema would only see an opaque string. + +Use structured target segments: + +```json +[ + "parameters", + "agent", + { + "field": "skills", + "key": "release-qa" + }, + "body" +] +``` + +A selector segment has exactly: + +```json +{ + "field": "skills", + "key": "release-qa" +} +``` + +The resolver knows the key field by collection: + +- `skills`: `name` +- `mcps`: `name` +- `files`: `path` +- `tools`: canonical effective tool name + +The tool identity problem is not limited to gateway tools: + +- Gateway `name` is optional [models.py](/home/mahmoud/code/agenta-2/sdks/python/agenta/sdk/agents/tools/models.py:89). +- Reference tools use `name or slug` [models.py](/home/mahmoud/code/agenta-2/sdks/python/agenta/sdk/agents/tools/models.py:190). +- Platform tools use `op` [models.py](/home/mahmoud/code/agenta-2/sdks/python/agenta/sdk/agents/tools/models.py:212). +- The current gateway fallback is adapter-derived `integration__action` [resolver.py](/home/mahmoud/code/agenta-2/sdks/python/agenta/sdk/agents/tools/resolver.py:100). + +Define one canonical `item_key` function in the SDK and server. For new or replacement gateway entries, I would require an explicit `name`. Continue reading legacy unnamed gateways through the fallback, but do not base the new mutation contract on an adapter-dependent derived name. + +Opaque `@ag.embed` entries are another unresolved identity case. Either exclude them from name-addressed operations or define a stable raw reference key. Resolving their current name is not stable if the referenced object later changes. + +### 3. Anchored edits + +Keep the intended Pi contract, not the implementation wholesale: + +- Exact substring matching. +- `old_text` must be non-empty. +- Exactly one occurrence. +- No regex. +- All entries matched against the same pre-operation string. +- Overlapping matches rejected. +- No-change replacements rejected. +- Atomic batch. + +Use `old_text` and `new_text`. The API, SDK schema, and persisted configuration use snake case. Pi’s camelCase is a TypeScript-local convention, while `old_string` is no clearer than `old_text`. + +One important correction: the local Pi implementation is not actually exact. Its schema advertises exact replacement [edit.js](/home/mahmoud/code/agenta/services/runner/node_modules/@earendil-works/pi-coding-agent/dist/core/tools/edit.js:11), but it falls back to NFKC, whitespace, quote, dash, and space normalization [edit-diff.js](/home/mahmoud/code/agenta/services/runner/node_modules/@earendil-works/pi-coding-agent/dist/core/tools/edit-diff.js:134). It also normalizes line endings and strips BOMs before matching [edit.js](/home/mahmoud/code/agenta/services/runner/node_modules/@earendil-works/pi-coding-agent/dist/core/tools/edit.js:201). + +Do not do that for JSON strings. It can modify bytes outside the intended replacement and make an anchor succeed against content the caller did not actually specify. If normalized matching is ever wanted, expose it later as an explicit `match_mode`, with `exact` as the default. + +### 4. Path references + +Make a workspace reference a value source inside a value-bearing operation: + +```json +{ + "operation": "add_item", + "target": ["parameters", "agent", "skills"], + "value_from": { + "type": "workspace", + "path": "downloaded-skills/pdf-tools" + } +} +``` + +Do not make it a top-level delta kind or a separate tool. Both alternatives disconnect the source from the intended mutation and make atomic add-or-replace behavior harder. + +Use explicit intent: + +- `add_item`: fail if the derived skill name exists. +- `replace_item`: fail if it does not exist. +- Never `upsert`. +- For replacement, require the parsed source name to equal the selected key. Renaming is an explicit remove plus add. + +Responsibility split: + +1. The runner confines the path to the workspace, rejects traversal and symlink escapes, reads each byte once, and converts the directory to a structured skill. +2. The canonical skill validator validates the result again server-side. `SkillTemplate` already defines name, body, file limits, and safe paths [models.py](/home/mahmoud/code/agenta-2/sdks/python/agenta/sdk/agents/skills/models.py:49). +3. The commit service checks collisions and validates the final complete agent config. + +Approval needs new runner behavior. Today context-bound values are filled at execution after model arguments [direct.ts](/home/mahmoud/code/agenta-2/services/runner/src/tools/direct.ts:220), while the approval card deliberately sees redacted model arguments [acp-interactions.ts](/home/mahmoud/code/agenta-2/services/runner/src/engines/sandbox_agent/acp-interactions.ts:579). That mechanism is insufficient. + +The runner must materialize and freeze workspace content before approval. The approval UI should show: + +- Add versus replace intent. +- Resolved item name. +- Body or script diff. +- File manifest, sizes, and digests. +- Total byte count. +- Any executable-file flags. + +Execution must use the frozen bytes approved by the human, not reread the directory afterward. + +`value_from` should be a runner-authoring extension. The runner replaces it with canonical inline `value` before calling the API. The normal invoke API should not pretend it can resolve paths inside an unrelated runner workspace. + +### 5. Base check + +Use `workflow_revision.base_revision_id`, beside `message` and `delta`. + +Do not place it inside `delta`: it is a precondition on the commit, not a mutation. + +Do not unconditionally hide it behind the existing context binding. The current binding mechanism overwrites model values. After a conflict, the agent would remain bound to its stale run revision and could never retry successfully in the same run. Keep it model-visible so the agent can reread and rebase. + +For old calls that omit it, the runner may default it from `$ctx.workflow.revision.id` only when absent. That is defaulting, not ownership binding, and should be implemented separately from `context_bindings`. + +Return HTTP 409 with both IDs: + +```json +{ + "detail": { + "code": "revision_conflict", + "message": "The workflow head changed. No revision was committed.", + "base_revision_id": "019c-old", + "current_revision_id": "019c-new", + "current_revision_version": "17", + "retryable": true + } +} +``` + +Yes, return the current head. It lets the agent retrieve the exact revision directly. Do not return the full configuration in the conflict response because that recreates the payload-size problem. + +The comparison must be atomic with insertion. Today `_resolve_revision_delta` fetches the head before the DAO starts its commit transaction [service.py](/home/mahmoud/code/agenta-2/api/oss/src/core/workflows/service.py:2000), while the DAO opens a separate transaction later [dao.py](/home/mahmoud/code/agenta-2/api/oss/src/dbs/postgres/git/dao.py:1606). A comparison added only to `_resolve_revision_delta` still races. Compare the current head and insert the new revision in one database transaction. + +### 6. Error model + +One failed operation must fail the whole commit. + +A revision is one coherent configuration, operations can depend on earlier operations, and a partial commit would make retries much harder. If preview or per-operation diagnostics are needed, add a non-persisting validation endpoint. Do not return partial mutation success from the commit endpoint. + +Use HTTP 422 for semantically invalid change sets and HTTP 409 for stale bases. Return a stable machine code, operation index, target, and corrective context: + +```json +{ + "detail": { + "code": "change_set_rejected", + "message": "No revision was committed.", + "operation_index": 1, + "operation": "edit_text", + "target": [ + "parameters", + "agent", + { + "field": "skills", + "key": "release-qa" + }, + "body" + ], + "reason": { + "code": "text_not_unique", + "message": "old_text matched 3 times. Include more surrounding text.", + "match_count": 3 + }, + "retryable": true + } +} +``` + +Useful reason codes include: + +- `target_not_found` +- `target_type_mismatch` +- `item_already_exists` +- `item_not_found` +- `duplicate_item_key` +- `text_not_found` +- `text_not_unique` +- `text_edits_overlap` +- `no_change` +- `source_not_found` +- `source_invalid` +- `source_too_large` +- `final_validation_failed` + +Return all final-schema issues when validation naturally produces several. During sequential application, stop at the first failing operation. + +### 7. Compatibility + +Keep `delta.set` and `delta.remove` exactly as they behave today: + +- `set` remains a recursive dictionary merge. +- Scalars and lists still replace. +- `remove` remains a dotted-path list. +- Legacy application order remains `set`, then `remove`. +- Do not reinterpret old payloads. + +But: + +- Do not allow legacy fields and `operations` in the same delta. +- Mark whole-list `set` on `tools`, `skills`, and `mcps` as legacy in model guidance. +- Return a warning when an old call replaces one of those lists wholesale. +- Preserve legacy missing-remove no-op behavior, but make new `remove` and `remove_item` strict. +- Keep the name `delta`. Renaming it now gains nothing. + +There is an unavoidable compatibility issue with a mandatory base. Existing playbooks omit it. To preserve them, require `base_revision_id` for ordered operations and let the runner default it for legacy tool calls when the run context has a committed revision. Warn on unguarded direct legacy API calls and sunset them separately. + +Also make the API Pydantic operation models `extra="forbid"`. The model-facing catalog is closed today [op_catalog.py](/home/mahmoud/code/agenta-2/sdks/python/agenta/sdk/agents/platform/op_catalog.py:721), but the current `WorkflowRevisionDelta` DTO itself does not forbid unknown keys [dtos.py](/home/mahmoud/code/agenta-2/api/oss/src/core/workflows/dtos.py:301). + +### 8. Reuse on invoke + +Use the same canonical change-set type and application engine, with different wrappers and policies. + +Commit wrapper: + +- Variant routing. +- `base_revision_id` must equal head. +- Persists one revision. +- Carries commit message. +- Full atomic transaction. + +Invoke override wrapper: + +- Existing revision reference selects revision X. +- No head comparison is needed because revisions are immutable. +- Scope policy permits only `parameters`. +- Never persists. +- No commit message. +- Returns the resolved revision ID used for the run. + +The workspace `value_from` form is not part of the canonical shared type. It is a runner-side authoring extension that becomes an inline `value` before either commit or invoke processing. + +Build a pure function shaped roughly like: + +```text +apply_change_set(base_data, delta, scope_policy) -> resolved_data +``` + +Both commit and run override should call it. Do not reuse `_resolve_revision_delta` as-is because it fetches the latest revision itself. `handle_test_run` currently resolves a request revision and then calls that method, causing the base to be fetched again [platform_handlers.py](/home/mahmoud/code/agenta-2/api/oss/src/core/tools/platform_handlers.py:207). The caller should resolve the base exactly once and pass its data to the shared application engine. + +The invoke scope guard must inspect every structured target, including nested selectors. The current guard only examines top-level `set` keys and dotted `remove` strings [platform_handlers.py](/home/mahmoud/code/agenta-2/api/oss/src/core/tools/platform_handlers.py:177), so it must be rewritten before ordered operations can safely reach invoke. + +In short: one canonical atomic operation engine, two wrappers, two policy profiles, and one runner-only source-materialization layer. + + diff --git a/docs/design/agent-config-editing/research/rfc.html b/docs/design/agent-config-editing/research/rfc.html new file mode 100644 index 0000000000..ff507e842c --- /dev/null +++ b/docs/design/agent-config-editing/research/rfc.html @@ -0,0 +1,1063 @@ +RFC: The agent edits its own configuration + + + +
+ +
+

Agenta · agent workflows · RFC / PRD · draft for discussion

+

The agent edits its own configuration

+ +
+ + + + +
+

1Summary

+

+ An agent in Agenta can edit its own configuration. Today, every edit must send the full + content again. This costs many tokens. Large payloads fail (#5554). Whole lists get + replaced by mistake. The agent cannot read its own configuration before it writes. +

+

+ This document defines what we want instead. It gives the user stories, the requirements, + and the design options for each open question. It ends with a proposed plan. The deep + research behind it is in the + companion report. +

+
+ + +
+

2Background: what happens today

+

+ The configuration is one JSON object. It holds the instructions (one long Markdown + string), the model, the tools, the skills, the MCP servers, the harness, and the + permissions. The agent saves with one tool: commit_revision. The tool takes a + change set: set (a partial object to merge) and remove (paths to + delete). +

+

+ The merge has one rule that causes most of the pain: it merges objects, but it replaces + scalars and lists whole. The instructions are one string. Skills, tools, and MCP servers + are lists. So every real edit is a full rewrite. +

+

Five facts from the research set the stage. All are verified in code.

+
    +
  • + The instruction file and the skill folders are already real files in the agent's + workspace. The runner writes them at session start. They land on the durable mount, so + they also exist in object storage. +
  • +
  • + The write-back path already exists. When the agent writes a file, the mount pushes it to + object storage on file close. The Files drawer reads it back. No upload step exists. +
  • +
  • + The runner can read files from a live sandbox. It does this on every tool call today. + It also injects server-owned fields into tool calls after the model's arguments, so the + model cannot fake them. +
  • +
  • + The session fingerprint keeps sessions correct. When the configuration changes, the next + turn rebuilds the files from the new configuration. This works with any number of runner + replicas, because the signal travels with the request. One hole exists on the approval + path (see section 7). +
  • +
  • + The draft exists only in the browser and in the runner's memory. No server endpoint can + return the configuration a draft run is executing. A server read returns the committed + head. +
  • +
+

workspace.ts:50-157 · relay.ts:255-300 · direct.ts:210-247 · session-identity.ts:199-259 · runnableSetup.ts:105-170

+
+ + +
+

3User stories

+ +
+

US-1Fix one line of the instructions

+

TodayThe agent must resend the whole instruction file. Above about 4.8 KB the call arrives cut off and fails (#5554). The agent loops on a wrong error message.

+

TargetThe agent sends one small edit: the old text and the new text. The cost is the size of the change, not the size of the file.

+
+ +
+

US-2Change one line in one skill

+

TodayThe agent must resend every skill, with every bundled file. Anything it forgets is deleted.

+

TargetThe agent names the skill and sends one edit for its body. All other skills stay untouched.

+
+ +
+

US-3Install a large skill from the internet

+

TodayThe agent downloads the skill into its workspace. Then it must retype the full content through a tool call. Large skills always hit the 4.8 KB failure.

+

TargetThe commit call points at the folder in the workspace. The platform reads the files from there. The content never passes through the model.

+
+ +
+

US-4Add or remove one tool

+

TodayThe agent must resend the full tool list. This is also how run-only platform tools leak into the stored configuration.

+

TargetThe agent adds or removes one entry by name. The rest of the list stays untouched.

+
+ +
+

US-5Read before write

+

TodayThe agent cannot read its own configuration. It guesses, and after a save it can report the wrong model to the user (#5186).

+

TargetThe agent reads its running configuration and its draft status before it edits. This also serves parts of the configuration that files do not cover, such as the model and the tools.

+
+ +
+

US-6Try a change without saving

+

TodayTo run with a changed configuration, you must commit it, or use the playground draft. API callers have no option at all.

+

TargetA run can carry a temporary change on top of a saved revision. No new revision is created.

+
+ +
+

US-7Two writers, no silent loss

+

TodayThe commit merges onto the latest committed head. If someone else moved the head, the write silently overwrites their change.

+

TargetA commit built on an old base fails with a clear error. The writer reads the new head and retries.

+
+ +
+

US-8Running sessions stay correct

+

TodayMostly correct: a config change makes the next turn start cold with fresh files. One hole: a config edit during an approval pause can leave a warm session on stale files.

+

TargetNo session ever runs a stale configuration. Faster refresh is optional; correctness is not.

+
+
+ + +
+

4Requirements

+ +
R1

The token cost of an edit must be proportional to the change, not to the configuration size.

+
R2

Large new content must move by reference to workspace files, not through the model's context or the tool-call channel.

+
R3

Every edit target must have a stable address. List entries are addressed by name, never by position. The platform must therefore enforce unique names for skills, tools, and MCP servers at save time. Today it does not (see section 7).

+
R4

A commit built on a base that has moved must fail with a clear error. It must never overwrite silently.

+
R5

The agent must be able to read its configuration before it writes, including single parts of it, and know which revision it got and whether it is a draft.

+
R6

The mechanism must work the same on all harnesses (Pi, Claude, Codex) and both sandboxes (local, Daytona).

+
R7

Builder capabilities stay playground-only, and the self-target guarantee stays: the agent can only edit itself. Server-owned fields must remain outside the model's control.

+
R8

A run with a temporary change must not create a revision, and must not require the playground.

+
R9

No credential enters the sandbox. All reads and writes keep passing through the relay.

+
R10

No session ever runs a stale configuration. The known approval-path hole gets fixed.

+
R11

The commit must validate the configuration shape and report a specific error. Bad shapes must not save cleanly and fail later.

+
R12

Every builder tool call accepts an optional description, written by the agent, that says what it is doing and why. The frontend shows it with the call and its result. This applies to the commit and to the other builder tools.

+
+ + +
+

5Design space

+

+ Six questions. The review on 4 August decided four of them. Two stay open: the read + tool's draft answer (Q3) and the reload table (Q5, Spike S2). Closed options stay in the + text, marked, so we do not reopen them by accident. +

+ + +
+

Q1How does the agent express an edit?

+

+ The research classified every configuration field. Two groups exist. Documents: the + instructions, skill bodies, skill files, code-tool scripts, and system-prompt overrides. + Structured data: everything else, including three lists (tools, skills, MCP servers) + whose entries need addressing by name. +

+ +
+
Option A: keep set and remove only (today)
+

+ Fails R1. Every document edit resends the document. Every list edit resends the list. + This is the current pain. +

+
+ +
+
Option B: add anchored edits and named list operationsdecided 4 aug
+

+ Add an edit mode next to set and remove. An + anchored edit says: in this field, find this exact text, replace it with this text. + The old text must appear exactly one time. Many edits can travel in one call. The + platform checks all edits first, then applies all or none. +

+

+ Pi's edit tool already implements these exact rules. Its matching engine is small, + pure, and built for a pluggable storage layer. We port it and point it at + configuration fields instead of files. It also outputs a unified diff. We show that + diff on the approval screen and store it with the commit. +

+

+ For lists, address entries by name: skills["release-qa"].body, + tools["send-slack-message"]. Add-one and remove-one operations complete + the set. This needs R3 (unique names) first. +

+
+ +
+
Option C: the agent edits the workspace files; a commit collects them
+

+ Most native for the agent: it uses its own file editor. The files already exist and + already persist on the mount. But three gaps are real today. The skill-to-disk format + loses data on the way back (executable flags, multi-line descriptions, binary files, + embedded references). The mount can silently fail to attach (#5342), and then a + server-side read sees nothing. And today the configuration always wins over the disk: + every commit rebuilds the files. Option C inverts that rule and must define who wins + when. +

+

+ Keep this as a second step, not the first. Option B and Option C share the same + engine: a file diff and a tool-call edit are the same operation. Nothing built for B + is lost if C follows. +

+
+
+ + +
+

Q2How does large content enter the configuration?

+

+ US-3 is the driving story. The content already exists as files in the workspace. The + question is who reads them into the commit. +

+ +
+
Option A: through the tool-call arguments (today)
+

Fails. The model retypes the content. Cost is high. Large calls die at 4.8 KB (#5554).

+
+ +
+
Option B: the runner reads the files and attaches themdecided 4 aug · spike S1
+

+ The commit call carries a path: "add the skill at ./downloaded-skills/pdf-tools/". + The runner reads the files and attaches the content to the call before it forwards it. +

+

+ Where exactly does this happen? Every tool call leaves the sandbox as a small file. + The runner, outside the sandbox, picks that file up. It then builds the HTTP request + in three layers: first the model's arguments, then fixed server values, then the + server-owned identity fields. The model can never overwrite the later layers. The new + read step slots in right there: the runner sees a path field in the call, reads those + files from the sandbox, and puts the content into the request body as one more layer. + The runner already reads sandbox files this way on every tool call today. New work: + the read step itself, and a folder-to-skill converter (the reverse of the one that + writes skills to disk). +

+

+ Spike S1 proves this end to end before we design more on top of it. +

+

pickup: services/runner/src/tools/relay.ts:255-300 · layering: services/runner/src/tools/direct.ts:210-247

+
+ +
+
Option C: the API reads the files from object storageclosed
+

+ Options B and C read the same files. The difference is who reads, and which copy. + The runner (B) reads the files inside the live sandbox: always present, always + current. The API (C) reads the copy in object storage: that copy appears only after + the file is closed and flushed, and when the mount silently failed to attach (#5342), + the copy does not exist at all while the agent still sees its files. Same files, less + reliable copy. Closed in favor of B. +

+
+
+ + +
+

Q3How does the agent read its own configuration?

+

+ The draft gap shapes this question. A server read returns the committed head. On a + draft run, that is not what is executing. The running configuration exists in only two + places: the browser, and the runner's memory for the current run. +

+ +
+
Option A: write the configuration as a file in the workspaceclosed
+

+ Closed in the 4 August review, for two reasons. First, the builder tools are + playground-only on purpose: when an agent is shared with other people later, those + people must not see its internals. A configuration file written into every run's + workspace breaks that split. Second, a file can go stale during a session, and then + the agent has no action to take. It can only wait for the next session. A tool always + gives the agent an action: call it again and get a fresh answer. +

+
+ +
+
Option B: a read_config tooldecided 4 aug
+

+ A playground tool, injected like the other builder tools, removed for shared agents + like the other builder tools. Three requirements on it: +

+
    +
  • + It returns the base the commit will write on. Read and write must agree. If + the agent reads X and edits X, the commit must apply to X. +
  • +
  • + It supports partial reads. "Give me the tool list." "Give me the skill named + release-qa." "Give me the model." The agent must not pay for the whole object to + check one field. +
  • +
  • + It says what it returns. The response carries the revision id and the draft + flag, so the agent knows which version it is looking at. +
  • +
+

+ The mechanics are cheap: the existing revision endpoint plus a self-binding covers + the whole-object read with no new backend code. Partial reads and response shaping + are the new work. One open point remains: on a draft run, the server does not have + the draft, so the tool returns the committed head. That head IS the base the commit + writes on today, so read and write agree. But it is not what is running. The tool + must say so, and the final answer depends on the commit-base decision (open question + 1). +

+
+ +
+
Option C: the runner answers the read from its memoryparked
+

+ The runner holds the running configuration in memory during a run. It could answer + the read locally. But its memory holds the config this run started with, not the + latest version after a commit. So it answers a different question ("what am I + running") than the one the agent needs before an edit ("what will my commit apply + to"). Parked until a real need for "what am I running" appears. +

+
+
+ + +
+

Q4How do two writers avoid silent loss?

+

+ Two sessions can edit the same agent. A user can edit the drawer while the agent works. + Today the last write silently wins over the head. +

+ +
+
Option A: a base check plus loud anchored editsdecided 4 aug
+

+ The commit carries the revision id it was built on. If the head moved, the server + refuses with a clear error. The writer calls read_config, gets the new + head, and retries. No locks. Anchored edits add a second net for free: if the target + text changed, the anchor does not match, and the edit fails loudly instead of + overwriting. Note the contrast: the current merge is silent when stale; the anchored + edit is loud when stale. +

+
+ +
+
Option B: locks
+

+ A session takes a lock on the agent before editing. Heavy, needs lease management, + and punishes the common case where no conflict exists. Not recommended. Gumloop, for + reference, has neither: one mutable draft row, last write wins. +

+
+
+ + +
+

Q5When the configuration changes, what happens to the running session?

+ +

First, what the fingerprint is and why it exists.

+

+ Starting a session from nothing is slow: about 12.5 seconds. So after a turn ends, the + runner keeps the sandbox and the harness session alive, parked, ready for the next + turn. Before it reuses a parked session, it must answer one question: is this session + still correct for the next turn? +

+

+ To answer it, the runner uses a fingerprint. A fingerprint is one checksum computed + over all the configuration values: the model, the harness, the instruction text, the + skills, the tools, the permissions, and more. When the runner parks a session, it + stores the checksum with it. When the next turn arrives, the runner computes the + checksum of that turn's configuration and compares. Same checksum: reuse the session, + 1.4 seconds. Different checksum: throw the session away and build a new one, 12.5 + seconds. +

+

+ The checksum is blunt on purpose. It cannot tell what changed. It only tells + that something changed. And today the runner has no way to apply any change to a + live session. So its only possible answer to any change, however small, is a full + rebuild. That is why changing one word of the instructions costs 12.5 seconds. +

+ +

The real design question: which changes truly need a rebuild?

+

+ Most configuration values are just files or data inside the sandbox. The instructions + are a file. The skills are folders. The tool list is data the harness receives. The + model is a parameter. In principle the runner could update these in place: rewrite the + file, update the folders, send the new tool list, set the new model, and keep the + session. A rebuild would then only be needed for the values that define the sandbox + itself: the harness kind and the sandbox provider. +

+

+ One thing must be verified per harness before we build this: does a live harness pick + up the change on its next turn? Example: Claude reads its instruction file when the + session starts. If we rewrite the file under a live session, the next turn may still + use the old text. The same question applies to skills, to the tool list, to MCP + servers, and to the model. Nobody has this table today. Building it is Spike S2. +

+ +
+
Option A: keep the full rebuild; fix the one correctness bug
+

+ This is the floor. Correct on any number of runner replicas, with zero coordination, + because the checksum travels with each request. The cost stays: every small change + pays the full rebuild. Two cheap improvements belong here regardless: on a checksum + mismatch, stop the sandbox instead of deleting it; and compute the checksum from the + configuration content only, not from the revision id, so a commit that changes + nothing keeps the session. +

+
+ +
+
Option B: update in place; rebuild only for harness and sandbox changesdecided 4 aug
+

+ The runner classifies each changed value. Most values get applied to the live + session: rewrite the instruction file, update the skill folders, refresh credentials + and environment values. Only the values that define the sandbox force a rebuild: the + harness kind and the sandbox provider. +

+

+ The review settled most of the reload question without a spike. Harnesses do not + re-read the instruction file or the skills on their own; you have to tell them. That + is correct harness behavior, and it is the harness's business, not ours. We update + the files; the harness reads them when it reads them. The same holds for API keys + and environment values on Daytona: updating them must not force a rebuild. +

+

+ One value stays open: tools. If the tool list changes, does a live harness + discover the new list by itself? If yes, tools update in place too. If no, a tool + change keeps today's behavior and rebuilds. This is now the whole of Spike S2. (One + lead to check: the tool channel speaks MCP, and MCP has a "tool list changed" + notification. Our shim could send it.) +

+
+ +
+
Option C: push a signal to running sessionsdeferred
+

+ Not needed for correctness. The pieces exist if we ever want it (the commit event is + already published to a Redis stream; a session-to-replica registry exists). It would + only make the refresh happen earlier. Revisit after Option B exists. +

+
+ +

The correctness bug, in simple words.

+

+ A run stops and waits for your approval. While it waits, you change the configuration. + You approve. The run continues in the old sandbox with the old files. That part is + correct: the turn started before your change. The bug comes at the end of the turn. + The runner parks the sandbox and writes a label on it: "this sandbox matches + configuration B" (your new one). But the sandbox still holds the files of + configuration A. The next turn compares labels, sees a match, and reuses the sandbox. + The session now runs on old instructions, and nothing detects it. The fix: write the + true label, A. Then the next turn sees the difference and rebuilds. Small, local + change in the runner. Decision: on the task list for the Q5 work, not a separate + project. +

+

services/runner/src/server.ts:596-603, 767-854

+
+ + +
+

Q6How does a run try a change without saving it?out of scope 4 aug

+

+ Moved out of scope in the 4 August review. The text stays for the record, because the + change-set format was designed so this can be added later without rework. +

+

+ Today a run has two choices. Use a saved revision exactly as stored. Or send a + complete configuration inline, which is what the playground does. There is no middle + way. You cannot say: "run revision X, but with model Y." +

+

+ The building blocks exist. The agent's test tool does exactly this internally: it + takes the saved configuration, applies a change in memory, runs once, and saves + nothing. But only the agent has that tool, and only on itself. +

+

+ The proposal: add one field to the normal run request. The field carries a change on + top of the referenced revision. The change uses the same format as Q1: set, remove, + edit. Nothing gets saved. The first user is the playground model picker: trying a + model stops forcing a draft. One point to settle: the change needs a base. A pinned + revision gives an exact base. An environment gives a moving base, whatever is deployed + that day. +

+
+
+ + +
+

6Decisions, spikes, and next steps

+ +

Decided on 4 August:

+
    +
  • Q1: Option B. Anchored edits plus named list operations, next to set and remove. Files-as-config stays a later step on the same engine.
  • +
  • Q2: Option B. The runner reads workspace files and attaches them to the commit. Spike S1 proves it first.
  • +
  • Q3: Option B. A read_config tool with partial reads. No configuration file in the workspace.
  • +
  • Q4: Option A. The base check, no locks.
  • +
  • Q5: Option B. Update in place; rebuild only for harness and sandbox changes. Harnesses not re-reading files on their own is accepted harness behavior. Spike S2 shrinks to one question: tool discovery on a live session. The approval-path bug goes on the task list of this work.
  • +
  • Q6: Out of scope. The format stays compatible so it can come back later.
  • +
  • New requirement R12: builder tool calls carry an optional agent-written description for the frontend.
  • +
+ +

Closed options. Do not reopen without new information:

+
    +
  • A CLI in the sandbox (the authentication problem).
  • +
  • Large content through the tool-call arguments (Q2 Option A).
  • +
  • The API reading the storage copy at commit time (Q2 Option C).
  • +
  • A configuration file in every workspace (Q3 Option A).
  • +
  • Locks for concurrent writers (Q4 Option B).
  • +
  • Rewriting files under a live harness before Spike S2 proves what reloads (old Q5 Option C).
  • +
+ +

Spikes, in order:

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
IDQuestion the spike answersOutput
S1Can a commit tool take a workspace path, have the runner read the files, and land them in the configuration? Smallest possible proof: one path argument, the existing runner read, the existing commit endpoint.A working demo, plus the list of problems it uncovers.
S2Narrowed on 4 August to one question: when the tool list changes, does a live harness discover the new list (per harness: Pi, Claude, Codex)? Check the MCP "tool list changed" notification as the likely mechanism. If discovery fails, tool changes keep today's rebuild.Tools: in-place or rebuild, per harness.
S4The runner lifecycle architecture. Design how the runner separates sandbox create/destroy, session open/close, and per-turn setup, with a harness port-and-adapter layer, and the fingerprint split into tiers that route to rebuild, session reload, or in-place update. Assigned to Codex (GPT), with our framing.A proposed module structure and migration path, for our review.
S3The interface design for the change set: set, remove, edit, path reference, and the base check, as one coherent schema. Assigned to Codex (GPT), briefed with the design-interfaces skill.Delivered 4 August. See below.
+
+ +
+

Spike S3 result: the proposed interface

+

+ Codex (GPT, highest reasoning setting) reviewed our first sketch against the code and + rejected it, with reasons. Its counter-proposal is below. We consider it strong and + take it as the working draft, pending our own review. +

+

+ The shape. The commit keeps its envelope: message, + delta, and a new base_revision_id beside them. The delta has + two forms, never mixed. The legacy form is today's set and + remove, unchanged, so shipped playbooks keep working. The new form is one + ordered list: operations. Each operation names its verb, its target, and + its payload. Operations apply in order, all or nothing. +

+

Seven verbs:

+
+ + + + + + + + + + + +
OperationMeaning
setReplace the target value exactly.
mergeDeep-merge an object, with today's rules.
removeRemove an object field. A missing target is an error.
edit_textApply anchored edits to one string: old_text, new_text, exact match, exactly one occurrence, all or nothing.
add_itemAdd a named entry to a list. A name collision is an error.
replace_itemReplace a named entry. A missing entry is an error.
remove_itemRemove a named entry. A missing entry is an error.
+
+

+ There is no "add or replace" verb on purpose. The model must say which one it means, + so the server can catch the case where reality disagrees. +

+

+ The target is a list of steps, not a string. A plain string step means an + object field. An object step means a named list entry: +

+

+ ["parameters", "agent", {"field": "skills", "key": "release-qa"}, "body"] +

+

+ This avoids a new string grammar with escaping rules. The schema can validate every + step. Nested lists work the same way: a skill file is + {"field": "files", "key": "scripts/check.py"}. The key field per list is + fixed: skills and MCP servers key by name, files by path, tools by their canonical + tool name. New third-party tool entries must carry an explicit name. +

+

+ Large content by reference. A value-bearing operation accepts + value_from: {type: "workspace", path: "downloaded-skills/pdf-tools"} + instead of an inline value. The runner resolves it: it confines the path to the + workspace, reads the files once, converts the folder to a skill, and replaces + value_from with a normal inline value before the call reaches the API. + The API never resolves paths; it only ever sees complete values. This also means the + approval screen needs frozen content: the runner reads the files before the approval, + shows the name, the diff, the file list with sizes, and commits exactly the approved + bytes. It must not re-read the folder after approval. +

+

+ Conflicts and errors. A stale base returns 409 with both revision ids, old and + new, so the agent can re-read and retry in one step. Any failed operation fails the + whole commit with 422, naming the operation index, the target, and a machine-readable + reason code such as text_not_unique with a corrective hint. No partial + commits. +

+

+ One engine, two wrappers. A pure function applies a change set to a base: + apply_change_set(base, delta, scope_policy). The commit wrapper checks + the base id and persists. The run-override wrapper (Q6) takes an immutable revision, + applies, runs, persists nothing. The workspace value_from is a + runner-side extension, resolved before either wrapper. +

+

Four catches from the review worth recording:

+
    +
  • + Pi's edit tool is not truly exact: on a miss, it retries with normalized quotes, + dashes, and whitespace. Fine for source files, dangerous for configuration strings. + We port the contract, not the fallback. Exact match by default; a relaxed mode only + ever as an explicit option later. +
  • +
  • + Do not server-bind base_revision_id like the variant id. Binding + overwrites what the model sends. After a conflict, the agent could then never retry + inside the same run, because the binding would keep stamping the stale id. The field + stays model-visible; the runner only fills it when it is absent. +
  • +
  • + The head comparison must happen inside the same database transaction as the insert. + Today the head is fetched in one place and the commit happens in another, so two + commits can still race between check and insert. +
  • +
  • + The delta DTO on the API side does not reject unknown fields today. It must, or + typos in operation names save silently. +
  • +
+
+ +

+ The phased delivery plan comes after the spikes report. Writing it earlier would guess at + answers the spikes exist to give. S1 can now use these field names. +

+
+ + +
+

7New defects found during this research

+

These came up while tracing the design space. None has an issue yet.

+
    +
  • + Stale configuration after an approval. A run stops and waits for your approval. + While it waits, you change the configuration. You approve, and the run finishes on the + old files, which is correct. Then the runner parks the sandbox with the wrong label: + it records the new configuration on a sandbox that holds the old files. The next turn + sees a match and reuses it. The session runs on old instructions and nothing detects + it. (Explained in full in Q5.) + server.ts:596-603, 767-854 +
  • +
  • + Two skills with the same name: one silently vanishes. The save accepts both. At + run time, the runner writes the first and silently skips the second. The user believes + both exist. + skills.ts:156-176 +
  • +
  • + Two tools with the same name: the error comes too late. A duplicate-name check + exists, but it runs at run time, not at save time. The bad configuration saves cleanly + and breaks the next run instead. + resolver.py:90-135 +
  • +
  • + A third-party tool can have no stable name. The name field is optional. Without + a stable name, we cannot address the entry for edits (requirement R3). + tools/models.py:87-96 +
  • +
  • + The durable storage fails silently. When the durable mount cannot attach, the + runner continues without it and only writes a log line. The agent sees its files. The + server sees nothing. The files die with the sandbox. Adjacent to #5342. + environment.ts:777-789, 835-845 +
  • +
+
+ + +
+

8Open questions

+
    +
  1. + The commit base for user drafts stays undecided (companion report, section 3.1). The + base-revision check in P1 makes staleness loud, but the product question remains: should + an agent save include the user's uncommitted edits, or refuse, or ignore them visibly? +
  2. +
  3. + Which configuration parts may the agent edit at all? Instructions and skills, clearly. + The model, the harness, the permissions: should those need a human commit? +
  4. +
  5. + What does the approval screen show for a path-reference commit (US-3)? The content did + not pass through the model, but a human still approves it. A file listing with sizes and + a diff seems right; this needs a UX decision. +
  6. +
  7. + Name collisions on import: the agent installs a downloaded skill whose name already + exists. Replace, rename, or refuse? +
  8. +
+
+ +
+

+ Companion research: Agent + self-configuration: how it works and what breaks. Prior art checked: Gumloop (no diff + mechanism, no concurrency handling; one mutable draft row, last write wins) and Pi's edit + tool (anchored, unique-match, all-or-nothing; engine is pure and storage-pluggable). +

+
+ +
diff --git a/docs/design/agent-config-editing/research/runner-lifecycle-codex.md b/docs/design/agent-config-editing/research/runner-lifecycle-codex.md new file mode 100644 index 0000000000..d8f6761009 --- /dev/null +++ b/docs/design/agent-config-editing/research/runner-lifecycle-codex.md @@ -0,0 +1,638 @@ +## Verdict + +Your framing is directionally right, but four tiers are not enough. + +The runner needs an explicit **runtime/daemon lifecycle** between harness session and sandbox. Today, credentials, Pi tool configuration, Claude provider settings, and Codex environment are installed when the agent daemon or sandbox starts. They cannot honestly be classified as turn-tier until a live refresh mechanism exists. + +Also, tiers should describe the **minimum invalidation boundary**, not permanently own request fields. Internally, the router should: + +1. Normalize the request into semantic facets. +2. Diff desired state against the environment’s actual applied state. +3. Ask the harness and provider adapters how each changed facet can be reconciled. +4. Produce an ordered action plan. +5. Update applied state only after each action succeeds. + +A single `max(changedTier)` switch is insufficient because a session reload may still require a workspace refresh first. + +I verified the structural claims in the current code. I did not independently verify the 12.5 s and 1.4 s latency measurements. + +## Verified problems in the current design + +The wholesale fingerprint does include model, harness, sandbox, instructions, skills, tools, MCP configuration, permissions, harness files, workflow revision, and draft state in [session-identity.ts:188](/home/mahmoud/code/agenta-2/services/runner/src/engines/sandbox_agent/session-identity.ts:188). + +The pool duplicates that request-derived identity beside the environment in [session-pool.ts:24](/home/mahmoud/code/agenta-2/services/runner/src/engines/sandbox_agent/session-pool.ts:24). That is the root abstraction error: the request says what was wanted, not what was successfully installed. + +The approval bug is real: + +- Normal re-parking receives the incoming `cfgFp` in [server.ts:592](/home/mahmoud/code/agenta-2/services/runner/src/server.ts:592). +- Approval resume does not reconcile the incoming configuration before continuing in [server.ts:767](/home/mahmoud/code/agenta-2/services/runner/src/server.ts:767). +- After the resumed turn, re-parking stamps the incoming fingerprint anyway in [server.ts:920](/home/mahmoud/code/agenta-2/services/runner/src/server.ts:920). + +The current tests actually preserve part of that behavior: a model change during approval is allowed to resume the existing session in [session-keepalive-approval.test.ts:1212](/home/mahmoud/code/agenta-2/services/runner/tests/unit/session-keepalive-approval.test.ts:1212). + +The teardown problem is also real. `compatibility-mismatch` falls through to deletion in [teardown.ts:23](/home/mahmoud/code/agenta-2/services/runner/src/engines/sandbox_agent/teardown.ts:23), while stopped-sandbox acquisition later attempts reconnect-by-pointer in [environment.ts:659](/home/mahmoud/code/agenta-2/services/runner/src/engines/sandbox_agent/environment.ts:659). + +## 1. Lifecycle model + +I would use this top-level execution flow: + +```text +Request admission + | + v +Session checkout + | + v +Desired-state reconciliation + | + +--> sandbox lifecycle + +--> runtime/daemon lifecycle + +--> mount lifecycle + +--> workspace lifecycle + +--> harness-session lifecycle + | + v +Turn lifecycle + | + +--> complete ------> park + +--> approval pause -> repark as suspended turn + +--> failure -------> teardown by disposition +``` + +### Lifecycle names and responsibilities + +| Lifecycle | Events | Responsibility | +|---|---|---| +| Request | `normalize`, `validate`, `plan` | Convert `AgentRunRequest` into semantic desired state. No side effects. | +| Parked session | `checkout`, `park`, `repark`, `expire`, `evict` | Own concurrency, idle timers, continuity metadata, and the environment lease. It does not decide compatibility. | +| Sandbox | `create`, `reconnect`, `reconfigure`, `stop`, `destroy` | Provider instance, image or snapshot generation, network boundary, persistent storage attachment. | +| Runtime/daemon | `bootstrap`, `start`, `attach`, `reconfigure`, `restart`, `stop` | Agent daemon, process environment, provider credentials, Pi extension assets, Codex home, Claude connection environment. | +| Mount | `attach`, `renewCredentials`, `verify`, `detach` | Workspace and agent mounts, signed leases, replica ownership. | +| Workspace | `materialize`, `refresh`, `verify`, `cleanManagedFiles` | `AGENTS.md`, `CLAUDE.md`, skills, system prompt files, harness configuration files. | +| Harness probe | `probe` | Determine functional and lifecycle capabilities for the installed harness/version. | +| Harness session | `open`, `loadNative`, `reopen`, `close` | ACP session and native conversation continuity. `reopen` may load the existing native conversation if continuity permits. | +| Turn | `setup`, `run`, `suspendForApproval`, `resume`, `finish`, `teardown` | Incoming messages, model selection, permission responder, callback binding, attachment preparation, telemetry, relay process. | + +### Mapping today’s `acquireEnvironment` + +The current lifecycle stages are all embedded in [environment.ts:252](/home/mahmoud/code/agenta-2/services/runner/src/engines/sandbox_agent/environment.ts:252): + +| Current timing | Target owner | +|---|---| +| `sandbox_start` | `SandboxLifecycle.acquire`, with separate `create` and `reconnect` events | +| `pi_install` and runtime asset upload | `RuntimeLifecycle.bootstrap` | +| `mounts` | `MountLifecycle.attachWorkspace` | +| `agent_mount` | `MountLifecycle.attachAgentStorage` | +| `prepare_workspace` | `WorkspaceManager.materialize` or `refresh` | +| `probe_capabilities` | `HarnessAdapter.probeLifecycleCapabilities` | +| `create_session` with `load` or `create` mode | `HarnessSessionLifecycle.open` or `loadNative` | +| `acquire_total` | Coordinator aggregate only | + +The cold setup currently includes: + +- Daytona runtime asset push in [environment.ts:709](/home/mahmoud/code/agenta-2/services/runner/src/engines/sandbox_agent/environment.ts:709). +- Mount setup in [environment.ts:763](/home/mahmoud/code/agenta-2/services/runner/src/engines/sandbox_agent/environment.ts:763). +- Workspace materialization in [environment.ts:871](/home/mahmoud/code/agenta-2/services/runner/src/engines/sandbox_agent/environment.ts:871). +- Capability probing in [environment.ts:934](/home/mahmoud/code/agenta-2/services/runner/src/engines/sandbox_agent/environment.ts:934). +- Native-session load or creation in [environment.ts:1012](/home/mahmoud/code/agenta-2/services/runner/src/engines/sandbox_agent/environment.ts:1012). + +`environment.destroy()` also needs decomposition. It currently closes the session, stops or deletes the provider, unmounts storage, cleans workspaces, and removes Pi state in one method at [environment.ts:289](/home/mahmoud/code/agenta-2/services/runner/src/engines/sandbox_agent/environment.ts:289). + +## 2. Fingerprint tiers + +I would stop calling the result one fingerprint. Use: + +- Content digests for non-secret manifests. +- Explicit generation identifiers for resources. +- Timing-safe secret-material comparison for credentials. +- A structured `AppliedEnvironmentState`. +- A structured delta between desired and applied state. + +### Tier table + +| Facet | Semantic owner | Normal route | Escalation or caveat | +|---|---|---|---| +| Sandbox provider | Sandbox | Rebuild | Provider changes currently select another pool, potentially leaving the previous provider’s environment parked. The coordinator should explicitly evict or transfer ownership. | +| Harness kind | Product topology policy | Rebuild | Although technically a runtime boundary, the stated product policy promotes it to sandbox rebuild. | +| Image, snapshot, target, immutable provider generation | Sandbox | Rebuild | Becomes `SandboxGenerationId`. | +| Network policy / `sandboxPermission` | Sandbox provider | Live provider reconfigure if strictly supported | Fail closed. Daytona currently treats some network updates as best effort, which is too weak for security policy. Otherwise rebuild. | +| Model selection | Harness session | Apply before next turn | Use `setModel`; promote to session reopen if unsupported. Current entry point is [model.ts:88](/home/mahmoud/code/agenta-2/services/runner/src/engines/sandbox_agent/model.ts:88). | +| Codex mode | Harness session | Apply live | Use `setConfigOption`; promote if unsupported. See [codex-mode.ts:21](/home/mahmoud/code/agenta-2/services/runner/src/engines/sandbox_agent/codex-mode.ts:21). | +| Model provider, endpoint, deployment, process environment | Runtime | Reconfigure or restart runtime, keeping sandbox | Current implementations bake these into daemon environment, so live refresh is not yet available. | +| Model/API credentials | Runtime/provider | Refresh credential delivery, then restart or reconnect affected client | Must never be represented by a normal content hash. Current Daytona create-time secret delivery prevents the desired behavior without a new seam. | +| Instructions | Workspace | Refresh managed files | Active harness observation is explicitly not guaranteed, per the product decision. | +| Pi system and append prompts | Workspace/runtime | Refresh managed files | Pi uses files under its agent directory. If the running process has captured their location or content, visibility remains not guaranteed. | +| Skills | Workspace | Refresh managed files | Pi currently points at content-addressed snapshots. A stable active path or symlink is needed for honest in-place refresh. | +| Harness files | Workspace plus harness-specific owner | Refresh files, then usually reopen session | These files are opaque and may encode security or startup settings. Default to session or runtime escalation unless the adapter classifies them. | +| Tool model-visible catalog | Harness session or runtime | MCP `list_changed` if proven | Claude/Codex currently receive MCP servers at session initialization. Pi tool specs are part of runtime extension configuration, so Pi is runtime-restart today. | +| Tool execution bindings | Turn | Replace before the next turn | Callback, auth, private execution metadata, and relay binding should not require session reload. | +| User MCP server definitions | Harness session | Live notification if supported, otherwise reopen | MCP credential rotation may additionally require client/session recreation. | +| Permission responder policy | Turn | Recompute every turn | This is already derived from incoming request policy. | +| Harness permission configuration files | Workspace/session | Refresh plus reopen | Security-sensitive. Do not accept stale behavior merely because stale instructions are accepted. | +| Callback endpoint and auth | Turn | Rebuild relay binding | The relay currently combines stale `env.plan.tools.toolSpecs` with current callback and run context in [run-turn.ts:822](/home/mahmoud/code/agenta-2/services/runner/src/engines/sandbox_agent/run-turn.ts:822). That split must become atomic. | +| `modelCapabilities` | Turn | Pass through | It is request/telemetry behavior, not environment identity. | +| Messages, `turnId`, context, telemetry | Turn | Pass through | Never environment identity. | +| Revision id, revision version, draft flag | Turn metadata only | Nowhere in compatibility identity | Preserve them in `runContext` for tool binding and observability. Remove them from environment comparison. | +| History fingerprint | Continuity subsystem | Separate admission gate | Do not mix it into environment reconciliation. | +| Credential epoch | Credential subsystem | Produces credential deltas | Keep timing-safe comparison, but replace unconditional eviction with targeted refresh. | +| Mount lease expiry | Mount subsystem | Renew or remount | Keep separate from model and MCP credential identity. | + +### Routing algorithm + +The router should return an ordered plan, not only a tier: + +```text +1. Normalize request into DesiredEnvironmentState + TurnPlan. +2. Diff DesiredEnvironmentState against env.appliedState. +3. Resolve each delta through harness and provider capabilities. +4. If any delta requires sandbox rebuild: + rebuild, then run the complete cold pipeline. +5. Otherwise: + refresh mounts and credentials + refresh managed workspace files + restart/reconfigure runtime if required + reopen/load harness session if required + apply live session settings +6. Build TurnPlan from the incoming request. +7. Run the turn. +8. Park the environment using env.appliedState, never request state. +``` + +The ordering matters. For example, changed harness files may require both `workspace.refresh` and `session.reopen`. + +### Applied state and the approval bug + +The pool should not store an independently supplied configuration fingerprint. The environment should own its actual state: + +```ts +interface AppliedEnvironmentState { + generation: number + + sandbox: { + provider: SandboxProvider + instanceId: string + generationId: string + networkPolicyDigest: string + } + + runtime: { + generation: number + configDigest: string + credentialMaterial: CredentialMaterial + } + + mounts: { + workspaceLeaseExpiresAt?: number + agentLeaseExpiresAt?: number + } + + workspace: { + manifestDigest: string + files: ReadonlyMap + } + + harnessSession: { + transportSessionId: string + nativeSessionId?: string + configDigest: string + activeModel?: string + toolCatalogGeneration?: string + } +} + +interface EnvironmentLease { + readonly appliedState: AppliedEnvironmentState + commitApplied(result: ReconcileResult): void + destroy(reason: TeardownReason): Promise +} +``` + +The pool API should become conceptually: + +```ts +pool.park(environment, continuity) +pool.repark(liveSession, continuity) +``` + +It must not accept `configFingerprint`, credential state, or any other request-derived claim. Compatibility is evaluated against `liveSession.environment.appliedState`. + +During approval resume: + +- The suspended prompt still belongs to its original applied environment generation. +- Non-safety configuration changes should be deferred until that prompt finishes. +- A model change from `m1` to `m2` must not be recorded unless `setModel(m2)` actually succeeded. +- If the resumed turn pauses again, it remains associated with `m1`. +- After completion, the coordinator can reconcile to `m2` before parking, or retain the pending delta for the next checkout. +- Permission tightening and credential revocation may require immediate fail-closed treatment rather than deferral. + +This makes the current bug structurally impossible because there is no request fingerprint parameter available to stamp. + +## 3. Harness port and adapter + +Do not add these fields to the current `HarnessCapabilities` protocol type. That type describes functional features such as permissions and session loading in [protocol.ts:334](/home/mahmoud/code/agenta-2/services/runner/src/protocol.ts:334). Lifecycle reloadability is runtime- and version-specific. + +A concrete port could look like this: + +```ts +export type ReconcileMechanism = + | "no-op" + | "apply-live" + | "refresh-workspace" + | "reopen-session" + | "restart-runtime" + | "rebuild-sandbox" + | "unsupported" + +export interface WorkspaceHandling { + mechanism: "refresh-workspace" | "reopen-session" | "restart-runtime" + activeSessionObservation: + | "immediate" + | "next-turn" + | "not-guaranteed" +} + +export interface HarnessLifecycleCapabilities { + model: ReconcileMechanism + mode: ReconcileMechanism + toolCatalog: ReconcileMechanism + mcpServers: ReconcileMechanism + mcpCredentials: ReconcileMechanism + + instructions: WorkspaceHandling + skills: WorkspaceHandling + harnessFiles: WorkspaceHandling +} + +export interface HarnessRuntimeFacts { + harness: HarnessKind + adapterVersion?: string + protocolVersion?: string + provider: SandboxProvider + probedCapabilities: ReadonlySet +} + +export interface HarnessAdapter { + readonly kind: HarnessKind + + capabilities( + facts: HarnessRuntimeFacts, + ): HarnessLifecycleCapabilities + + project( + desired: NormalizedDesiredState, + ): HarnessDesiredState + + renderWorkspace( + desired: HarnessDesiredState, + ): Promise + + buildRuntimeSpec( + desired: HarnessDesiredState, + ): Promise + + buildSessionSpec( + desired: HarnessDesiredState, + ): Promise + + applyLive( + session: AgentSession, + delta: LiveHarnessDelta, + ): Promise + + openSession( + runtime: AgentRuntime, + spec: HarnessSessionSpec, + ): Promise + + loadSession( + runtime: AgentRuntime, + spec: HarnessSessionSpec, + nativeSessionId: string, + ): Promise + + closeSession(session: AgentSession): Promise +} +``` + +The provider needs its own port. Credential and environment reloadability is not a harness-only concern: + +```ts +export interface ProviderLifecycleCapabilities { + networkPolicy: + | "reconfigure-live" + | "rebuild-sandbox" + + runtimeEnvironment: + | "restart-runtime" + | "rebuild-sandbox" + + credentialDelivery: + | "refresh-live" + | "restart-runtime" + | "rebuild-sandbox" +} + +export interface SandboxProviderAdapter { + readonly kind: SandboxProvider + + capabilities(): ProviderLifecycleCapabilities + + create(spec: SandboxCreateSpec): Promise + reconnect(pointer: SandboxPointer): Promise + + reconfigure( + sandbox: SandboxHandle, + delta: SandboxMutableDelta, + ): Promise + + restartRuntime( + sandbox: SandboxHandle, + spec: HarnessRuntimeSpec, + ): Promise + + stop(sandbox: SandboxHandle): Promise + destroy(sandbox: SandboxHandle): Promise +} +``` + +Every mutation returns what was actually applied. The reconciler updates `AppliedEnvironmentState` only from those results. + +### Current adapter matrix + +| Harness | Model | Tool/MCP catalog | Instructions and skills | Harness files | +|---|---|---|---|---| +| Pi | Likely `apply-live` through generic `setModel`, but must be tested | `restart-runtime` today because public tool specs and extension setup are startup assets in [pi-assets.ts:341](/home/mahmoud/code/agenta-2/services/runner/src/engines/sandbox_agent/pi-assets.ts:341) | Refresh files; active observation not guaranteed | Adapter-specific, default runtime restart | +| Claude | Likely `apply-live`, with reopen fallback | `reopen-session` until S2 proves MCP list change support | Refresh files; active observation not guaranteed | Default session reopen because settings can affect permissions/startup | +| Codex | Model `apply-live`; mode through `setConfigOption` | `reopen-session` until S2 proves live discovery | Refresh files; active observation not guaranteed | Default session reopen; some `CODEX_HOME` changes may require runtime restart | + +“Likely” matters here. The runner exposes the calls, but the adapter/version must prove the behavior. Capabilities should be versioned or probed, with a conservative fallback. + +The current non-Pi MCP server list is supplied through session initialization in [mcp.ts:329](/home/mahmoud/code/agenta-2/services/runner/src/engines/sandbox_agent/mcp.ts:329). Pi uses a different extension/relay path. A single global `supportsToolReload` boolean would erase that distinction. + +Tools should also be split using the existing public/private distinction in [public-spec.ts:1](/home/mahmoud/code/agenta-2/services/runner/src/engines/sandbox_agent/tools/public-spec.ts:1): + +- `ToolCatalogManifest`: what the model sees. +- `ToolExecutionPlan`: callback, credentials, routing, private metadata. + +Both should carry one catalog generation. A turn must never advertise generation N while the relay executes generation N+1. + +## 4. Module layout + +Suggested target: + +```text +services/runner/src/ + server.ts + + lifecycle/ + session-coordinator.ts + reconciliation-router.ts + reconciliation-plan.ts + applied-state.ts + desired-state.ts + state-diff.ts + approval-resume.ts + teardown-policy.ts + + pool/ + session-pool.ts + environment-lease.ts + + identity/ + pool-key.ts + history-continuity.ts + credential-material.ts + mount-lease.ts + content-digest.ts + + environment/ + environment-controller.ts + sandbox-lifecycle.ts + runtime-lifecycle.ts + mount-lifecycle.ts + workspace-manager.ts + harness-session-lifecycle.ts + timing.ts + + harnesses/ + port.ts + registry.ts + pi-adapter.ts + claude-adapter.ts + codex-adapter.ts + + providers/ + port.ts + local-provider.ts + daytona-provider.ts + + turns/ + turn-plan.ts + turn-runner.ts +``` + +### What moves where + +`server.ts` should retain: + +- HTTP and SSE handling. +- Authentication and request decoding. +- Concurrency/watchdog handling. +- Calling `SessionCoordinator.run(request)`. + +The dispatch, warm gate, approval resume, eviction choice, and re-parking logic at [server.ts:359](/home/mahmoud/code/agenta-2/services/runner/src/server.ts:359) through [server.ts:936](/home/mahmoud/code/agenta-2/services/runner/src/server.ts:936) move into `session-coordinator.ts`. + +`session-pool.ts` remains a map, lease, and timer implementation. It should not know why two configurations are compatible. + +`session-identity.ts` should be dissolved into its real concerns: + +- Pool key. +- History continuity. +- Credential material comparison. +- Mount leases. +- Generic content digest helpers. + +`environment-setup.ts` should become a pure planner. It currently derives the plan but also mutates Pi state and constructs daemon environment in [environment-setup.ts:168](/home/mahmoud/code/agenta-2/services/runner/src/engines/sandbox_agent/environment-setup.ts:168). Those side effects belong to runtime and workspace controllers. + +`environment.ts` becomes a thin `EnvironmentController` that composes the lifecycle units. It should not contain provider creation, workspace I/O, session setup, model mutation, listener installation, and teardown policy in the same module. + +`run-turn.ts` remains the turn engine, but it must receive a fresh `TurnPlan`. It should not read stale request-derived tool configuration from `env.plan`. + +Dependency direction: + +```text +server + -> SessionCoordinator + -> SessionPool + -> ReconciliationRouter + -> EnvironmentController + -> TurnRunner + +EnvironmentController + -> SandboxProviderPort + -> HarnessAdapter + -> WorkspaceManager + -> MountLifecycle + +Adapters do not import server or pool policy. +``` + +## 5. Migration path + +Each step can ship independently. + +### 1. Characterization and narrow safety fixes + +- Add regression tests for revision-only, draft-only, approval/model-change, credential-change, and teardown behavior. +- Remove workflow revision id, revision version, and draft flag from the current environment fingerprint. +- Introduce distinct teardown reasons: + - `session-incompatible` + - `runtime-incompatible` + - `sandbox-incompatible` + - `continuity-invalid` +- Map only known session/workspace incompatibilities to Daytona `stop`. +- Keep true sandbox incompatibility mapped to `destroy`. +- When destroying, atomically clear the reconnect pointer so the next acquire does not attempt the deleted sandbox. + +Do not make a blind one-line change that maps every `compatibility-mismatch` to stop. Credentials and Pi runtime assets would then survive in a stale daemon. + +### 2. Make identity environment-owned + +- Add `AppliedEnvironmentState` to `SessionEnvironment`. +- Initialize it from the successful cold-acquire results. +- Remove `configFingerprint` and credential inputs from `park` and `repark`. +- Make reconciliation the only code allowed to commit applied state. +- Add the critical regression: + - Park with model `m1`. + - Resume an approval with request `m2`. + - If no successful `setModel(m2)` occurred, the environment remains applied as `m1`. + - The following checkout must see an `m1 -> m2` delta. + +This kills the approval bug class before changing the broader routing behavior. + +### 3. Extract the coordinator + +Move the warm gate, approval path, miss path, eviction, and re-parking policy out of `server.ts`. Preserve current behavior. + +Keep history continuity and credential checks exactly as they are during this extraction. + +### 4. Introduce normalized desired state and shadow routing + +- Split the request into semantic facets. +- Calculate facet-level deltas. +- Run the new router in shadow mode. +- Compare its proposed boundary against current cold/warm decisions in logs and tests. +- Initially route all material configuration changes to the existing cold path. + +This lets naming and ownership stabilize without silently changing reuse behavior. + +### 5. Split `environment.ts` into lifecycles + +Extract sandbox, runtime, mounts, workspace, and harness-session controllers. Preserve behavior and old timing metrics, while adding the new event names. + +Implement `session.reopen` on the same sandbox before enabling it for any delta. + +### 6. Enable low-risk in-place routes + +In order: + +1. Revision/draft metadata no longer affects reuse. +2. Instructions refresh. +3. Skill refresh. +4. Model `setModel` before the next ordinary turn. +5. Codex mode live update. +6. Claude/Codex MCP and tool changes reopen the harness session on the same sandbox. +7. Pi tool changes restart the runtime on the same sandbox. + +Workspace refresh must be manifest-based, including deletions. The current `prepareWorkspace` mainly writes desired files in [workspace.ts:56](/home/mahmoud/code/agenta-2/services/runner/src/engines/sandbox_agent/workspace.ts:56); it does not robustly remove files or skill directories that disappeared from the request. + +### 7. Integrate Spike S2 + +Flip `toolCatalog` from `reopen-session` to `apply-live` only for the exact harness/version combinations proven to handle MCP `tools/list_changed`. + +Keep the fallback declaration in the adapter. This should be a capability-data change, not another coordinator rewrite. + +### 8. Implement credential/runtime refresh + +This is a real feature, not a fingerprint change: + +- Separate credential material by consumer. +- Refresh mount credentials independently. +- Define how model credentials reach an already-created Daytona sandbox or restarted daemon. +- Restart only the runtime where live injection is impossible. +- Reopen MCP clients when their header credentials change. +- Commit the new credential material only after successful refresh. + +Until this exists, the current safe fallback for some Daytona credential changes remains rebuild, contrary to the desired product behavior. + +### 9. Split Daytona creation identity + +The current Daytona create fingerprint hashes the full create request and secret plan in [provider.ts:95](/home/mahmoud/code/agenta-2/services/runner/src/engines/sandbox_agent/provider.ts:95), and reconnect destroys the sandbox when it differs in [daytona-secret-provider.ts:212](/home/mahmoud/code/agenta-2/services/runner/src/engines/sandbox_agent/daytona-secret-provider.ts:212). + +Replace it with: + +- `SandboxGenerationId`: provider, image/snapshot, target, immutable topology. +- `AppliedNetworkState`. +- `AppliedRuntimeState`. +- Private `AppliedCredentialMaterial`. +- Operational settings such as lifecycle timeouts, explicitly classified as mutable or next-create-only. + +On reconnect: + +- Generation mismatch means destroy and recreate. +- Mutable-state mismatch means reconnect and reconcile. +- Failed reconciliation leaves applied state unchanged and fails closed. + +## 6. Top risks + +1. **Stamped-but-not-applied state** + + Any API that lets callers supply the new identity recreates the approval bug. Applied state must be committed only from successful lifecycle results. + +2. **Approval resumes are in-flight turns** + + Applying a new model, tool catalog, or runtime while a tool call is awaiting approval can split one logical turn across configurations. Defer ordinary changes until the prompt finishes. Treat policy tightening and credential revocation separately. + +3. **Advertised tools and executed tools can diverge** + + Today the relay can use `env.plan.tools.toolSpecs` while callback and run context come from the incoming request. Catalog and execution plan need one atomic generation. + +4. **Credential refresh is not currently supported by every runtime** + + Daytona environment and secret attachment are create-time concepts today. Renaming them “turn-tier” does not make them refreshable. The provider port must expose a real operation or retain a safe rebuild fallback. + +5. **Workspace refresh can leave stale files** + + Removed skills, instructions, and harness files must be deleted. Use a runner-owned manifest and atomic replacement where possible. Never recursively clean user-owned workspace content. + +6. **Pi’s skill path is content-addressed** + + New content produces another snapshot, but the running process may still point at the previous digest. Use a stable active path/symlink or classify the update as runtime restart. See [pi-assets.ts:113](/home/mahmoud/code/agenta-2/services/runner/src/engines/sandbox_agent/pi-assets.ts:113). + +7. **Security policy may be accidentally treated like ordinary config** + + Runner approval policy can be per-turn, but harness preapproval files and sandbox network rules are longer-lived. Tightening must take effect or fail closed before execution continues. + +8. **History continuity can be damaged during session reopen** + + Keep [session-continuity.ts](/home/mahmoud/code/agenta-2/services/runner/src/engines/sandbox_agent/session-continuity.ts) and the history fingerprints separate from configuration reconciliation. A successful transport reopen does not itself prove native history was loaded. + +9. **Credential epoch and mount expiry can be conflated** + + Preserve the existing nonlogging, timing-safe credential comparison in [session-identity.ts:512](/home/mahmoud/code/agenta-2/services/runner/src/engines/sandbox_agent/session-identity.ts:512). Credential rotation should generate consumer-specific deltas; mount expiry should generate a mount-renewal action. + +10. **Provider changes can leak parked environments** + + Local and Daytona currently use separate pools in [server.ts:943](/home/mahmoud/code/agenta-2/services/runner/src/server.ts:943). Switching providers may create a second live environment while the first waits for TTL. A global session registry or explicit cross-provider eviction is needed. + +11. **Capability claims can drift by harness version** + + A generic ACP method existing does not prove Pi, Claude, and Codex implement it identically. Capability declarations should include the adapter/protocol version and use conservative fallbacks. + +12. **Partial reconciliation** + + Workspace refresh may succeed and session reopen may fail. The reconciler must either record the successfully applied subset accurately or run compensating recovery. It must never stamp the final desired digest after partial success. + +The key architectural rule is: **requests describe desired state; environments own applied state; adapters define the cheapest valid transition between them.** That gives you the routing flexibility you want without turning fingerprints into another source of truth. + + diff --git a/docs/design/agent-config-editing/status.md b/docs/design/agent-config-editing/status.md new file mode 100644 index 0000000000..db6ba7a9f8 --- /dev/null +++ b/docs/design/agent-config-editing/status.md @@ -0,0 +1,39 @@ +# Status + +Updated: 2026-08-04, by team-lead. + +## Where we are + +Phase 1 (spikes). The planning workspace is being committed and the draft PR opened. +Both spike teammates are running in worktrees: + +- engine-spike: prototyping the change-set engine (task #2). +- runner-spike: value_from proof, tools-discovery verdict, lifecycle characterization + tests (task #3). + +## Decisions taken (4 August review with Mahmoud) + +- Edits: ordered operations with anchored text edits and named list entries (RFC Q1 + Option B; interface per `research/change-set-interface-codex.md`). +- Large content: the runner reads workspace files and inlines them before the API sees + the call (RFC Q2 Option B). +- Config reads: a `read_config` tool with partial reads; no config file in the + workspace (RFC Q3 Option B). +- Concurrency: base check on commit, no locks (RFC Q4 Option A). +- Sessions: update in place, rebuild only for harness and sandbox changes (RFC Q5 + Option B). Harnesses not re-reading files on their own is accepted behavior. Open + question is tools only (Spike S2). The approval-path stale-config bug is fixed inside + this work, not separately. +- US-6 (run without saving) is out of scope. +- New requirement R12: optional agent-written description on builder tool calls. +- Scope of the PR set: full runner lifecycle refactor included. Frontend minimal. + +## Blockers + +None. + +## Waiting on + +- Spike reports (tasks #2, #3), expected in `spikes/`. +- Mahmoud: none right now. Product calls surfaced by the spikes will be brought to him + at the phase 1 exit gate (task #4). From df70c8ecdb33c994cf6b21fcd2ca0508043ece3a Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Tue, 4 Aug 2026 21:17:07 +0200 Subject: [PATCH 02/36] docs(design): spike reports, consolidated decisions, status update Both spikes are green. The decisions log records what was accepted from each spike and the seven product calls still open. --- docs/design/agent-config-editing/decisions.md | 76 +++ .../spikes/engine-spike.md | 352 +++++++++++++ .../spikes/runner-spike.md | 462 ++++++++++++++++++ docs/design/agent-config-editing/status.md | 18 +- 4 files changed, 902 insertions(+), 6 deletions(-) create mode 100644 docs/design/agent-config-editing/decisions.md create mode 100644 docs/design/agent-config-editing/spikes/engine-spike.md create mode 100644 docs/design/agent-config-editing/spikes/runner-spike.md diff --git a/docs/design/agent-config-editing/decisions.md b/docs/design/agent-config-editing/decisions.md new file mode 100644 index 0000000000..5d7e76ecec --- /dev/null +++ b/docs/design/agent-config-editing/decisions.md @@ -0,0 +1,76 @@ +# Decisions log + +Each entry records a decision, who made it, and where it applies. The spikes' full +reasoning is in `spikes/engine-spike.md` (D1-D33, O1-O12) and `spikes/runner-spike.md`. + +## Accepted from the engine spike (team lead, 4 August) + +- All engine decisions D1-D33 stand, with three amendments below. +- **Amendment to D4 (auto-creation):** `set` creates missing OBJECT parents on the way to + its last segment. It never creates a list entry. Reason: without this, setting a field + inside an absent `extras` bag needs two operations, and the second is a `set` of an + empty object. `merge` stays strict (D5 unchanged). +- **Amendment to D15 / answer to O1 (overlap counting):** count occurrences WITH overlap. + Two overlapping matches give `text_not_unique`. This diverges from Pi and is safer for + code and indented Markdown. +- **Answer to O4 (match_mode):** the `edit_text` operation carries `match_mode` from day + one. Only `exact` is allowed. Adding a mode later is then not a breaking change. +- **Answer to O8 (warnings):** the commit response carries a `warnings` list. The engine + returns warnings to the wrapper; the wrapper owns the response. +- **Answer to O9 (no-effect commits):** the engine reports whether the result equals the + base. The commit wrapper then refuses to create a revision and answers with a + `no_change` warning and the current head id. This also serves the session-reuse goal: + no new revision, no eviction. +- **Answer to O10:** the legacy/strict asymmetry is accepted for the transition. +- **Answer to O11:** `out_of_scope` stays 422 with its reason code. +- **Answer to O12:** `service.py` imports `deep_merge` from the engine. One home. + +## Accepted from the runner spike (team lead, 4 August) + +- `value_from` resolution runs at the permission gate, frozen per tool-call id, with + inline resolution at execution as the fallback for ungated calls. +- The workspace reader is its own abstraction with two implementations: local `node:fs`, + and a Daytona one-shot exec manifest (`find` + `realpath`), because the Daytona FS API + has no mode bits and no symlink information. +- The tools-discovery verdicts replace the lifecycle document's assumptions: + - Pi: apply-live is reachable. Ship tool specs as a file instead of an env var, add an + extension hook that re-reads it and calls Pi's live `registerTool` / `setActiveTools`. + A removed tool is hidden, not deregistered; Pi has no deregister API. + - Claude: apply-live is reachable. Our shims must advertise the MCP + `tools.listChanged` capability and emit the notification; Claude's side is built. + The local HTTP shim has no push channel and stays reopen-session in v1. + - Codex: reopen-session. The ACP adapter bakes MCP config at session creation. + - MCP server-list changes: reopen-session on every harness. No live API exists. + - Observation timing is planned as next-turn everywhere until measured. + - Prerequisite for all of it: remove `customTools` from the eviction fingerprint in + the same change that adds a live path. +- The characterization tests are the contract for the lifecycle refactor: slices 5-7 + must edit them deliberately. + +## Product calls confirmed by Mahmoud + +(Empty. The seven open calls below move here once answered.) + +## Open product calls (waiting on Mahmoud) + +1. **Storage normalization (engine O2, O3).** Normalize configuration strings once on + write: Unicode to NFC, line endings to LF. Exact matching then stays honest. + Recommended: yes. Risk: stored bytes change on the next write of old fields. +2. **Unique-name enforcement and old configurations (engine O7).** A configuration that + already holds a duplicate name would become uncommittable under a global check. + Recommended: enforce per commit only for collections the commit touches, warn on the + rest, and file a cleanup migration separately. +3. **Embedded skills stay unaddressable in v1 (engine O6).** An agent with an + `@ag.embed` skill cannot edit it by name; it must use a whole-list `set`. + Recommended: accept for v1, design a stable embed key later. +4. **Ungated `value_from` (runner Q1).** When the run's permission policy raises no + approval gate, the folder content is committed without a human seeing it. + Recommended: follow the run's policy (no forced gate); the policy owner opted out. +5. **Binary files in skill folders are dropped with a warning (runner Q3).** A skill + with a PNG or a compiled helper loses that file in v1. The eventual fix is a blob + `uri` file variant. Recommended: accept for v1, flag in the approval card. +6. **`value_from` reach (runner Q4).** Any path under the workspace root, or a + designated subfolder only? Recommended: whole workspace; the approval manifest is + the control. +7. **Pi tool removal means hidden (runner Q5).** The tool stays registered but the + model cannot see or call it. Recommended: accept. diff --git a/docs/design/agent-config-editing/spikes/engine-spike.md b/docs/design/agent-config-editing/spikes/engine-spike.md new file mode 100644 index 0000000000..e7d720f56e --- /dev/null +++ b/docs/design/agent-config-editing/spikes/engine-spike.md @@ -0,0 +1,352 @@ +# Spike: the change-set engine + +Task #2 of the agent-config-editing project. Owner: engine-spike. Date: 4 August 2026. + +## 1. What this spike proves + +The ordered-operations delta works as a pure function. One function applies a change set to +a base data tree. It has no database, no HTTP, and no pydantic models. The commit wrapper +and the invoke wrapper can both call it. + +The prototype implements the full interface spec at +`docs/design/agent-config-editing/research/change-set-interface-codex.md`: the two delta +forms, the seven operations, the structured targets, the anchored text edits, the error +model, and the scope policy. + +## 2. What I built and where + +Both files sit in the worktree `agent-a2a2adaa5d154d454`. They are not committed. + +| File | Size | Content | +|---|---|---| +| `api/oss/src/core/workflows/change_set.py` | ~700 lines | The engine. | +| `api/oss/tests/pytest/unit/workflows/test_change_set.py` | ~1200 lines | 120 tests. | + +Run the tests with `cd api && uv run pytest oss/tests/pytest/unit/workflows/test_change_set.py`. +All 120 tests pass. `ruff format` and `ruff check` are clean. + +### The public surface + +```python +apply_change_set(base, delta, scope_policy=None, *, validate=None) -> dict +``` + +- `base` is the resolved base revision data. The engine never changes it. +- `delta` is a legacy delta or an ordered delta. +- `scope_policy` gets each target. It returns a refusal message, or `None` to allow. + `PARAMETERS_ONLY` is the invoke wrapper's policy. `subtree_scope([...])` makes others. +- `validate` is the final-validation hook. It gets the finished tree. +- The function raises `ChangeSetError` on any failure. `ChangeSetError.to_detail()` gives + the HTTP 422 body the spec prescribes. + +Four more parts are public, because the SDK, the runner, and the server all need them: + +- `apply_text_edits(text, edits)` — the anchored-edit engine alone. +- `item_key(collection, entry)` — the one canonical key function the spec asks for. +- `deep_merge(base, patch)` — today's merge, moved to one home. +- `Reason` — the reason-code vocabulary. + +### What the engine does not do + +The engine does not fetch the head revision. It does not compare `base_revision_id`. It +does not resolve `value_from`. It does not persist. Those belong to the wrappers. + +## 3. Implicit decisions + +The spec does not settle these points. I made a decision for each one, and I recorded it +here. Please accept, change, or reject each one. This section is the real output of the +spike. + +### 3.1 Delta form + +**D1. "Present" means "not null", not "key exists".** A pydantic dump carries +`{"set": null, "remove": null, "operations": null}`. If the engine looked only for the key, +every ordered delta would also look legacy. The engine looks at the value. + +**D2. An empty delta is an error.** The spec says "at least one field required" for the +legacy form, but says nothing about a delta with no field at all. The engine refuses it +with `invalid_delta`. + +**D3. Unknown delta fields are an error.** This matches the `additionalProperties: false` +rule in the spec. + +### 3.2 Auto-creation + +**D4. Ordered operations do not create missing parents.** `set` on +`["parameters", "agent", "nope", "deeper"]` fails with `target_not_found`. Only the last +segment of a `set` may be new. + +Reason: the agent template is a closed schema (`extra="forbid"`). An auto-created path +always fails final validation. A precise error at the operation is better than a vague +schema error at the end. This is also a real difference from the legacy `set`, which +creates every level. The two forms now disagree on purpose. + +**D5. `merge` needs an existing object target.** A missing target gives +`target_not_found`. A non-object target gives `target_type_mismatch`. The legacy `set` +would have created the object. `merge` does not. + +### 3.3 Verbs and target tails + +**D6. Each verb accepts only one kind of last target segment.** + +| Verb | Last segment | Why | +|---|---|---| +| `set`, `merge`, `remove`, `edit_text` | a plain string | It addresses an object field. | +| `add_item` | a plain string (the list's field name) | It appends to a list. | +| `replace_item`, `remove_item` | a `{field, key}` selector | It addresses one named entry. | + +Reason: without this rule, `set` on a selector does the work of `replace_item`, and +`remove` on a selector does the work of `remove_item`. The spec says the model must state +its intent. Two ways to say one thing defeats that. + +A selector in the middle of a target stays legal for every verb. A skill body, a skill +file, and a tool field are all reachable. + +### 3.4 Item identity + +**D7. An `@ag.embed` entry has no key.** It is not addressable by name. `item_key` returns +`None` for it. An operation that names a key skips it. This follows the spec's advice to +exclude opaque embeds until a stable raw reference key exists. + +**D8. A gateway tool is readable by its legacy name, but not writable without one.** The +engine reads an unnamed gateway entry as `{integration}__{action}`, so old configurations +stay addressable. `add_item` and `replace_item` refuse a gateway value with no `name`. The +reason code is `item_key_undefined`. This is the spec's split, made concrete. + +**D9. A duplicate key is found only in the collection an operation touches.** The engine +does not scan the whole tree. A duplicate gives `duplicate_item_key` with a `match_count`. +R3 in the RFC wants unique names everywhere. That belongs to the final validator, not to +this engine. + +**D10. `remove_item` refuses to act on a duplicate key.** It does not remove both entries, +and it does not remove the first one. The caller must fix the configuration first. + +**D11. `replace_item` must keep the key.** The engine compares the key in the target with +the key it derives from the new value. A difference gives `invalid_operation`. The spec +asks for this. A rename is `remove_item` plus `add_item`. + +**D12. `add_item` appends to the end.** The operation has no position field. Order in +`skills`, `tools`, and `mcps` has no meaning today. + +**D13. Only four collections take item operations.** They are `skills`, `mcps`, `files`, +and `tools`. `add_item` on any other list gives `unkeyed_collection`. A permission list +such as `harness.permissions.allow` holds plain strings, so it has no key field. + +### 3.5 Anchored text edits + +**D14. Matching is exact on the bytes. Nothing is normalized.** The engine does not apply +NFKC. It does not fold smart quotes, dashes, or special spaces. It does not trim trailing +whitespace. It does not fold CRLF to LF. It does not strip a BOM. This follows the spec, +and it is the main deliberate difference from Pi. + +The cost is real, and the design review should see it. A model that writes `"a - b"` +against a stored `"a — b"` gets `text_not_found`. A model that writes `\n` against a +stored `\r\n` gets `text_not_found`. The error is loud and correct, but the agent must +retry with the true bytes. The `read_config` tool (Q3) must therefore return the exact +stored string, with no cleanup on the way out. + +**D15. Occurrences are counted without overlap.** The engine uses `str.count`. Pi uses a +split, which behaves the same way. So `"aa"` in `"aaa"` counts as one occurrence, and the +engine replaces at index 0. Two overlapping positions exist, so the anchor is not truly +unique. See open question O1. + +**D16. Adjacent edits are legal; overlapping edits are not.** Two edits that touch but do +not share a character both apply. The rule is `previous_end > current_start`. Pi uses the +same rule. + +**D17. Two edits with the same anchor give `text_edits_overlap`, not a duplicate error.** +Both match at the same index, so they overlap. + +**D18. `no_change` applies to the whole batch.** One edit that changes nothing does not +fail the batch, if another edit in the same batch changes something. Pi does the same. + +**D19. An empty `old_text` gets its own reason code, `empty_old_text`.** The spec's list +has no code for it. `text_not_found` would mislead the model. + +### 3.6 Purity and copying + +**D20. The engine deep-copies the base before it starts.** The caller's base always +survives, even on success. + +This fixes a real aliasing defect in today's code. `service._deep_merge` copies each level +shallow. So a branch the patch does not touch stays shared with the base. Then +`service._remove_path` deletes through that shared branch, and the base changes too. Today +the base comes from a fresh `model_dump`, so nothing breaks. A shared engine cannot rely on +that. A test pins the old behavior, so we notice if `service.py` changes. + +**D21. The engine deep-copies every value it writes.** The result never aliases the request +payload. Two operations that write the same value object stay independent. + +**D22. `set` with `value: null` writes null.** It does not remove the field. `remove` is +the verb that removes. + +### 3.7 Scope policy + +**D23. The scope check runs before any operation applies.** A refusal is a policy answer. +It must not depend on how far the change set already got. The error still names the +operation index, so the model knows which operation was refused. + +**D24. The policy signature is `target -> refusal message or None`.** A message, not a +boolean, so the refusal can say what is wrong. The reason code is `out_of_scope`. + +**D25. The policy also guards the legacy form.** The engine turns a legacy delta into +targets. It walks the `set` tree down to the policy's prefix depth. It splits each `remove` +path on the dot. With the one-level prefix `["parameters"]`, this gives exactly today's +behavior in `_validate_delta_scope`, including the case where `remove: ["parameters"]` +deletes the whole allowed subtree. Today's guard allows that, so the engine allows it too. + +**D26. A target shorter than the scope prefix is refused.** With the prefix +`["parameters", "agent"]`, a target of `["parameters"]` is out of scope, because writing it +would rewrite the subtree's parent. + +**D27. The engine reads the whole target, including nested selectors.** Today's invoke +guard reads only top-level `set` keys and dotted `remove` strings. A structured target +would go straight past it. A test pins the new behavior. + +### 3.8 Errors + +**D28. Six reason codes were added.** The spec's list is marked "useful reason codes +include", so I treated it as open. The new codes are: + +| Code | When | +|---|---| +| `invalid_delta` | Both forms, neither form, or an unknown delta field. | +| `invalid_operation` | A shape error the schema should also catch. | +| `empty_old_text` | An `edit_text` anchor is empty. | +| `unkeyed_collection` | The list has no key field. | +| `item_key_undefined` | The new entry has no derivable key. | +| `out_of_scope` | The scope policy refuses the target. | + +**D29. `retryable` comes from a table, not from a guess.** `out_of_scope`, +`invalid_delta`, `invalid_operation`, and `source_too_large` are not retryable. Everything +else is. A retry with the same payload never helps for the first four. + +**D30. `value_from` inside the engine is an error.** The reason code is `source_invalid`. +The runner must turn a workspace source into an inline value first. The engine never reads +a path. This makes the spec's rule enforceable, not only documented. + +**D31. Exactly one of `value` and `value_from` must be present.** Both give +`invalid_operation`. Neither gives `invalid_operation`. + +**D32. Final validation is a hook, not built in.** The caller passes a function. The +function returns a list of issues, or raises. Either way the engine raises one +`ChangeSetError` with `final_validation_failed` and an `issues` list. The spec asks for all +schema issues at once, so the list is a list. + +### 3.9 Warnings + +**D33. The engine has no warning channel.** The spec asks for a warning when a legacy call +replaces `tools`, `skills`, or `mcps` wholesale. A warning is a response-shaping concern. +It belongs to the commit wrapper, which owns the response. See open question O8. + +## 4. Edge cases tested + +The suite has 120 tests. These are the ones that carry information. + +**Legacy parity.** Eleven legacy deltas run through both the engine and the real +`service._deep_merge` / `service._remove_path`, and the results must match. The cases +include a whole-list replacement, a scalar that replaces a dict, a null value, a missing +remove path, a scalar in the middle of a remove path, and a `set` followed by a `remove` of +the same key. A separate test pins `deep_merge` against `service._deep_merge` on six +shapes. + +**The aliasing defect.** One test proves the engine leaves the base alone. One test proves +`service.py` does not. + +**Ordering.** An `add_item` followed by an `edit_text` on the new item works. A +`remove_item` followed by an `add_item` performs a rename. A `merge` sees an earlier `set`. + +**Atomicity.** A three-operation change set that fails at index 1 leaves the base +untouched, and the error names index 1. + +**Text matching.** Six tests prove that no normalization happens: smart quotes, an em dash, +a non-breaking space, trailing whitespace, CRLF, and decomposed Unicode all fail to match +their plain form. One test proves a BOM stays part of the string. + +**Text batches.** Disjoint edits apply together. Out-of-order edits apply correctly. +Adjacent edits apply. Overlapping edits fail. Two identical anchors fail. An edit that +names text an earlier edit wrote fails, because all anchors match the pre-operation string. +An all-no-change batch fails. A mixed batch with one real change succeeds. An edit with an +empty `new_text` deletes text. + +**Nesting.** A skill file two selectors deep is readable, settable, and editable: +`["parameters", "agent", {skills: release-qa}, {files: scripts/check.py}, "content"]`. + +**Tool identity.** A platform tool answers to its `op`. A reference tool answers to its +`slug` when it has no `name`. An unnamed gateway answers to `notion__create_page`. A +gateway value with no name cannot be added. + +**Shape errors.** An empty target, a selector with only `field`, a selector with an extra +`index` key, an unknown verb, and a value on `remove` all fail with `invalid_operation`. + +**Type errors.** `edit_text` on an object, `merge` on a list, `add_item` on a dict, and a +target that walks into a scalar all fail with `target_type_mismatch`. + +**Scope.** A `parameters` target passes. A `uri` target fails. A nested selector under a +forbidden root fails. Both legacy fields are guarded. A two-level prefix walks the legacy +`set` tree. + +## 5. Open questions for the design review + +**O1. Overlapping occurrences.** `"aa"` occurs at two positions in `"aaa"`, but the count +says one, so the engine accepts the anchor and replaces the first position. Pi has the same +behavior. Should the engine count overlapping positions instead, and answer +`text_not_unique`? This case is rare in prose. It is not rare in code and in indented +Markdown. + +**O2. Unicode form at the storage boundary.** Two clients can send the same text in two +Unicode forms. The playground textarea, the SDK, and the agent do not agree by +construction. With exact matching, a stored NFD string breaks every NFC anchor forever. Do +we normalize once, when a configuration string is stored, and keep matching exact? That is +a different decision from Pi's match-time normalization, and it is safer. + +**O3. Line endings.** The same question, for CRLF. A configuration string that holds +`\r\n` is very hard for an agent to edit. Do we normalize line endings on write? + +**O4. A later `match_mode`.** The spec proposes `match_mode` with `exact` as the default. +Should the operation carry the field from day one, with only `exact` allowed, so adding a +mode later is not a breaking change? + +**O5. Auto-creation for `set`.** D4 refuses missing parents. Is that too strict for the +`extras` bags, where a whole object may legitimately not exist yet? The alternative is +"create objects on the way to the last segment, but never create a list entry". + +**O6. Embed identity.** D7 makes `@ag.embed` entries invisible to named operations. An +agent that has one embedded skill cannot remove it with `remove_item`. It must fall back to +a legacy whole-list `set`, which is exactly what we are trying to stop. Do we need a raw +reference key for embeds, or a positional escape hatch? + +**O7. Where does R3 live?** The RFC wants unique names for skills, tools, and MCP servers +at save time. The engine finds a duplicate only in the collection it touches. Does the final +validator own the global check? If yes, every existing configuration with a duplicate name +becomes uncommittable. That needs a migration answer. + +**O8. The warning channel.** Spec section 7 asks for a warning when a legacy call replaces +a keyed list wholesale. Where do warnings live in the response, and does the invoke wrapper +carry them too? + +**O9. A no-change change set.** The engine refuses a no-change `edit_text`. It does not +refuse a change set whose total effect is zero. Q5 wants a content checksum, so that a +commit which changes nothing keeps the warm session. Should the engine report "nothing +changed" so the commit wrapper can skip the revision? + +**O10. Legacy `remove` is a silent no-op; ordered `remove` is strict.** The spec asks for +exactly this. It means the same agent gets two different answers for the same mistake, +depending on the delta form. Is that acceptable for the transition period? + +**O11. HTTP status for a scope refusal.** `out_of_scope` is a policy refusal, not a +semantic error. 422 groups it with the data errors. Should the invoke wrapper answer 403 +instead? + +**O12. Where does `deep_merge` live?** The engine holds a copy. `service.py` still holds +the original. They must never drift. The engine's copy should become the only one, and +`service.py` should import it. That is a one-line change, and this spike did not make it. + +## 6. Follow-up work this spike did not do + +1. The pydantic operation models, with `extra="forbid"` and the discriminated union. The + engine validates shape itself today, so the schema and the engine will overlap. +2. The commit wrapper: the `base_revision_id` check, in the same transaction as the insert. +3. The invoke wrapper: replace `_validate_delta_scope` with `PARAMETERS_ONLY`. +4. The final validator: `AgentTemplateSchema` validation that returns all issues. +5. `service.py` should import `deep_merge` from the engine (O12). diff --git a/docs/design/agent-config-editing/spikes/runner-spike.md b/docs/design/agent-config-editing/spikes/runner-spike.md new file mode 100644 index 0000000000..5732915c75 --- /dev/null +++ b/docs/design/agent-config-editing/spikes/runner-spike.md @@ -0,0 +1,462 @@ +# Runner spike report + +Three spikes for the agent-config-editing project. + +- Part 1 (S1): prove `value_from: {type: "workspace"}` end to end. +- Part 2 (S2): can a live harness session discover a changed tool list? +- Part 3: characterization tests for lifecycle migration step 1. + +All code is in the worktree. Nothing is committed. + +| File | Purpose | +|---|---| +| `services/runner/src/tools/skill-codec.ts` | Folder to `SkillTemplate` codec. New. | +| `services/runner/src/tools/workspace-reader.ts` | The filesystem port. New. | +| `services/runner/src/tools/value-from.ts` | The resolution step. New. | +| `services/runner/tests/unit/skill-codec-value-from.test.ts` | Part 1 tests. 34 tests. New. | +| `services/runner/tests/unit/session-lifecycle-characterization.test.ts` | Part 3 tests. 15 tests. New. | + +The full unit suite passes: 102 files, 1608 tests. `tsc --noEmit` is clean. + +--- + +## Part 1 — `value_from` end to end + +### What works + +The runner can resolve a workspace folder into an inline skill value. The spike proves it with 34 +tests against real temp directories. + +The flow has two halves. + +1. `readSkillFolder()` reads one folder and builds a `SkillTemplate`-shaped value. It is the exact + inverse of `resolveSkillDirs()` in `services/runner/src/engines/skills.ts`. `SKILL.md` + frontmatter gives `name` and `description`. Everything after the closing `---` is the `body`. + Every other file becomes one `files[]` entry with a relative POSIX path, UTF-8 `content`, and + `executable` from the owner-execute mode bit. +2. `resolveValueFrom()` walks the commit-tool call arguments, finds each `value_from`, calls the + codec, and replaces `value_from` with an inline `value`. It returns a deep copy. The model's + original arguments stay intact. + +One test round-trips the two directions. It reads a folder, feeds the value back through the real +`resolveSkillDirs()` materializer, reads the materialized directory again, and asserts the two +values match. + +### Key decisions in the codec + +**Output is snake case.** The value lands in the API's pydantic `SkillTemplate` +(`sdks/python/agenta/sdk/agents/skills/models.py`), which is snake case with `extra="forbid"`. It +does not land on the runner's camelCase `/run` wire. So the codec emits `disable_model_invocation` +and `allow_executable_files`, not the wire's camelCase names. Getting this backwards would fail +validation server-side. + +**Confinement uses two checks, not one.** A lexical check rejects an absolute path and any `..` +segment before anything touches the filesystem. A realpath check then rejects a symlink escape. +Both are needed. A lexical check alone misses a symlinked folder. A realpath check alone accepts a +path that never should have been formed. Every bundled file is realpath-checked again during the +walk, because a symlink deep in the tree escapes just as well as one at the root. Tests cover a +symlinked folder, a symlinked file, and a symlink that legitimately stays inside. + +**The walk is narrow.** `resolveValueFrom()` only inspects +`args.workflow_revision.delta.operations[i]`. It does not search the whole argument blob for a +`value_from` key. A broad search would let the string `value_from` buried in unrelated author text +trigger a filesystem read. That is a capability the model should not get by accident. A test pins +this. + +**File order is deterministic.** Directory entries are sorted before the walk. A digest over the +produced value is only useful if the same folder always produces the same bytes, and the approval +card needs a stable digest. + +### Codec gaps, and how each was handled + +| Gap | Handling | +|---|---| +| **Executable flags** | The mode bit is read and carried through to `files[].executable`. But a folder cannot express `allow_executable_files`, which is a *policy* claim, not a fact about the bytes. The codec derives it: true when at least one file is executable, false otherwise. This is a **punt**. It means a folder containing an executable file silently opts the skill into the executable-file policy. The materializer still needs the sandbox exec policy to agree (`engines/skills.ts` defaults to `deny`), so nothing becomes executable without a second decision. The approval card must show the executable flag explicitly. `resolveValueFrom()` returns `hasExecutableFiles` for that. | +| **Multi-line descriptions** | Handled. The wire needs a single-line description (the materializer already folds newlines to spaces). The codec folds a multi-line description to one line and adds a warning so the user learns the text was reshaped. YAML folded (`>-`) and literal (`\|`) block scalars are both parsed. | +| **Binary files** | Skipped with a warning. `SkillFile.content` is a text field with a `max_length` in code points. Base64-ing binary bytes into a text field would be dishonest and would blow the cap. The codec decodes strict UTF-8 and drops anything that fails. This is a **real product gap**: a skill folder with a PNG or a compiled helper loses that file silently apart from the warning. The SDK model comment already anticipates a future `uri` variant for blob storage; that is the right fix. | +| **Oversized files** | A single file over the cap is skipped with a warning and the skill still commits. A folder over the *total* byte budget fails the whole operation with `source_too_large`. The split is deliberate: one large reference file should not block the skill, but a folder that is wholesale too big is a mistake the user must see. | +| **YAML parsing** | The codec hand-rolls a minimal frontmatter parser. The runner has no YAML dependency and adding one for two scalar fields is not worth the supply-chain surface. It handles bare, double-quoted, single-quoted, folded, and literal scalars, and ignores unknown keys. It does **not** handle nested maps, lists, anchors, or multi-document files. A skill using those in frontmatter loses those fields. That is acceptable for `name`/`description` but should be documented for users. | +| **Symlinks to directories** | Followed, after the confinement check passes. Classification is by probe: a listable target is a directory, anything else is a file. | +| **Path length and file count** | Capped, with warnings. Depth is capped at 8. | +| **Duplicate names** | Not the codec's problem. `add_item` versus `replace_item` collision handling belongs to the commit service, per the interface doc. | + +### Where the resolution step should really live + +The brief pointed at two seams. Neither is right on its own. + +**`assembleBody` in `tools/direct.ts` (~213-247) is the wrong layer.** That function merges the +model's arguments with the server-fixed `body` and the run-context bindings. It is pure, synchronous, +and knows nothing about a filesystem or a workspace. Resolution is asynchronous, does I/O, and can +fail in ways that need their own error codes. Putting it there would make a pure merge function do +network-shaped work. + +**`executeAllowedRelayedTool` in `tools/relay.ts` (~363-408) is the right *execution* seam but the +wrong *timing*.** It is where `assembleBody` is called, and inserting `resolveValueFrom(req.args)` +just before that line works. But it runs **after** the approval gate, not before. + +That ordering is the real finding. The sequence today is: + +1. The model emits the tool call. +2. The harness raises an ACP permission gate. `acp-interactions.ts` (~560-582) builds the approval + card from `envelope.input`, the model's raw arguments. +3. The human approves. +4. The in-sandbox child writes a relay request file. +5. The runner's relay loop executes it. + +If resolution happens at step 5, the human at step 3 approves a card that reads +`value_from: {path: "downloaded-skills/pdf-tools"}`. They approve a **path**, not the bytes. They +cannot see the body, the file manifest, the sizes, or whether anything is executable. The interface +doc is explicit that the approval card must show all of that, and that execution must use the frozen +approved bytes rather than reread the directory. + +So resolution belongs at **step 2**, where the runner first observes the tool call, with the result +frozen and keyed by `toolCallId`. Concretely: + +- Resolve in the permission-gate path, so `acp-interactions.ts` can render the real manifest. +- Store the resolved value in a per-turn map keyed on `toolCallId`. `ParkedApproval` already carries + `args` and an `interactionToken`, so it is the natural place to hang the frozen value. +- At step 5, `executeAllowedRelayedTool` looks up the frozen value by `req.toolCallId` instead of + re-resolving. If the map has no entry (an ungated tool call, or a run with no approval policy), + resolve inline there as the fallback. + +This also closes a real hole. The relay directory is sandbox-writable. Without a frozen value, a +forged relay record could carry a different `value_from` path than the one that was approved. The +existing `RelayExecutionGuard` (`tools/relay.ts` ~120-129) exists for exactly this class of problem, +and a frozen-value lookup is the same defense applied to the value source. + +**A second placement problem: the reader port.** The codec deliberately takes a `WorkspaceReader`, +not a `RelayHost`. `RelayHost` (`tools/relay.ts` ~188-222) cannot do this job. Its `list` is flat and +returns bare names with no entry types. Its `read` returns text, not bytes. It has no realpath and no +mode-bit probe. A test asserts this explicitly. + +Worse, on Daytona the workspace lives **inside the sandbox**, so the resolution must read over the +daemon API, not `node:fs`. The sandbox-agent FS API (`node_modules/sandbox-agent/dist/index.d.ts` +lines 340-367, 3248-3254) is missing two things the codec needs: + +1. **No mode bits.** Neither `FsEntry` nor `FsStat` carries a permission mask. `isExecutable` cannot + be answered from the FS API at all. +2. **No symlink type.** `FsEntryType` is `"file" | "directory"` only. A symlink is indistinguishable + from its target, and there is no `realpath` equivalent. **The symlink-escape defense cannot be + built on the FS API alone.** + +Both are answerable with one `runProcess` exec per folder rather than per file: +`find -printf '%y\t%m\t%P\n'` plus `realpath ` returns the whole manifest in one round +trip. Doing it per entry would be one remote exec per file, which is far too slow. `workspace-reader.ts` +documents this and throws rather than shipping a wrong Daytona reader. + +**Net recommendation.** Build a `WorkspaceReader` abstraction with two implementations (local +`node:fs`, Daytona one-shot exec manifest). Resolve at the permission gate. Freeze by `toolCallId`. +Fall back to inline resolution in `executeAllowedRelayedTool` for ungated calls. + +--- + +## Part 2 — the tools-discovery verdict + +### Question + +When the tool list changes, can a **live** harness session discover the new list without a session +rebuild? + +### Verdict table + +| Harness | How we deliver tools today | Harness-side live mechanism | Verdict | +|---|---|---|---| +| **Pi** | Extension `registerTool`, fed by the `AGENTA_AGENT_TOOLS_PUBLIC_SPECS` **process env var**, read once at extension load | **Yes, and it is not MCP.** `registerTool` triggers a live `refreshTools()`; `setActiveTools` can hide a tool. | **Apply-live plausible — we need a channel, not a Pi change** | +| **Claude** | Our MCP shim: loopback HTTP (local) or in-sandbox stdio (Daytona) | **Yes.** Claude Code registers a `tools/list_changed` handler and refreshes its tool cache in place. | **Apply-live plausible — blocked on our shim, not on Claude** | +| **Codex** | Same MCP shim path as Claude | **No.** codex-acp bakes MCP servers into config at `session/new` and has zero `list_changed` handling. | **Needs session reopen** | + +Every harness is blocked today. For Pi and Claude the blocker is **our** side. For Codex it is the +adapter's. + +### Evidence: our own shims are the blocker for the MCP harnesses + +Both shims advertise MCP tool capability **without** `listChanged`: + +- `services/runner/src/tools/tool-mcp-http.ts:124` — `capabilities: { tools: {} }` +- `services/runner/src/tools/tool-mcp-stdio.ts:183` — `capabilities: { tools: {} }` + +This matters because the MCP client only wires a handler when the **server advertises the +capability**. From the bundled MCP SDK client +(`@modelcontextprotocol/sdk@1.29.0 dist/esm/client/index.js:121`): + +> `if (config.tools && this._serverCapabilities?.tools?.listChanged)` + +and its own comment at line 117: + +> "Handlers are silently skipped if the server doesn't advertise the corresponding listChanged capability." + +So today the notification would be ignored even if we sent one. + +Both shims hand-roll JSON-RPC on node builtins. Neither uses an MCP SDK, so both would need the +capability flag and the push added by hand. + +**The HTTP shim has no way to send one at all.** It is stateless JSON-only. `tool-mcp-http.ts:366-370` +rejects every non-POST verb with 405, and the header comment at lines 22-27 says why: + +> "stateless JSON mode ... we always answer a request with a single `application/json` JSON-RPC +> response (no SSE) ... `405` for the `GET`/`DELETE` stream-management verbs." + +The GET SSE stream is exactly the server-to-client channel Streamable HTTP uses for notifications. +Adding `listChanged: true` to the local path alone would do nothing. + +**The stdio shim could send one.** stdio is bidirectional and the shim owns stdout for the life of +the session (`tool-mcp-stdio.ts:291-333`, a long-lived readline loop). Writing an unsolicited +notification line is mechanically trivial; the code just never does it, because every write is +gated on an inbound message. But the tool list is loaded **once at process start** from the specs +file (`loadShimConfig`, `:123`, passed to `runToolMcpStdio` at `:343`), so the shim would also need +to watch that file or take a signal from the relay directory it already polls. + +### Evidence: Claude is ready on its side + +The shipped Claude binary +(`@anthropic-ai/claude-agent-sdk-linux-x64@0.3.205/.../claude`, 257 MB) contains Claude Code's own +handler, not just the vendored SDK's schema. At byte offset ~245505607: + +> `if($.capabilities?.tools?.listChanged)$.client.setNotificationHandler(Hmt,async()=>{ ... "Received tools/list_changed notification, refreshing tools" ... })` + +The body deletes the cached tool list, re-fetches, and emits a `tengu_mcp_list_changed` telemetry +event with `previousCount` and `newCount`. There is a documented failure path: + +> "tools/list failed after list_changed — keeping previous tool set" + +The refresh is applied to **live state, not a session rebuild**. The updater strips that server's +old tools by name prefix and splices the new list into the global `mcp.tools` state, batched at +16 ms. No `session/new` occurs. + +**The gate is `$.capabilities?.tools?.listChanged`**, read from the server's initialize result. Our +shim never sets it, so this handler is never registered. + +**What this does not prove.** Whether a request **already in flight** re-reads that state is not +determinable from a minified bundle. The state feeding tool assembly is updated live and no teardown +occurs. Treat the mechanism as proven and **in-turn timing as unverified**. Plan for `next-turn` +observation, not `immediate`. + +### Evidence: Pi can do it live, over a channel we have not built + +This is the finding that most changes the picture. My first read said "runtime restart". That was +wrong, and the correction matters. + +**The delivery path is genuinely a dead end.** Pi tool specs ride a process environment variable, +read once when the extension loads: + +- `services/runner/src/engines/sandbox_agent/pi-assets.ts:390-395` — + `env.AGENTA_AGENT_TOOLS_PUBLIC_SPECS = JSON.stringify(specs)` +- `services/runner/src/extensions/agenta.ts:250` — `process.env.AGENTA_AGENT_TOOLS_PUBLIC_SPECS`, + parsed once, `registerTools(pi)` called once from the factory at `:373` + +An environment variable cannot be changed on a running process. **MCP is not an option either**: +`@earendil-works/pi-coding-agent@0.80.6` ships no MCP client at all (no `*mcp*` file in `dist/`, no +`list_changed` anywhere in the tree), and the runner short-circuits MCP for Pi +(`mcp.ts:373`, `capabilities.ts:123` — `mcpTools: !isPiHarness`). + +**But Pi's own extension API supports live tool changes.** `registerTool` is not load-time only: + +`pi-coding-agent/dist/core/extensions/loader.js:184-191` +> `registerTool(tool) { runtime.assertActive(); extension.tools.set(...); runtime.refreshTools(); }` + +Post-bind, `refreshTools` is wired to a live implementation +(`dist/core/extensions/runner.js:167` -> `dist/core/agent-session.js:1859` +`refreshTools: () => this._refreshToolRegistry()`). `_refreshToolRegistry` rebuilds the registry from +built-ins plus extension tools and **auto-activates any name not in the previous registry** +(`agent-session.js:1979-1986`). + +The public `ExtensionAPI` also exposes `getAllTools()`, `getActiveTools()`, and +`setActiveTools(toolNames)` (`dist/core/extensions/types.d.ts:917-921`). + +So, mid-session, a Pi extension can: + +- **Add** a tool — `registerTool` refreshes and auto-activates it. +- **Hide** a tool from the model — `setActiveTools` without that name. +- **Truly deregister** a tool — **not possible.** There is no `unregisterTool`; the only unregister + in the API is `unregisterProvider`, for model providers. Hiding is the available substitute, and + it is what the model actually sees. + +What is missing is purely a **channel**: our extension reads env once and has no way to learn a new +list. A runner-written specs file plus an extension hook that re-reads it and calls `registerTool` / +`setActiveTools` would work with no Pi change. That path is speculative in its details but rests on +documented, verified APIs. + +This is a meaningful correction to the lifecycle doc, which lists Pi as `restart-runtime` on the +strength of the startup-asset delivery. The delivery is a startup asset; the harness is not the +constraint. + +### Evidence: Codex is the real reopen case + +Codex is not an npm dependency of the runner. It is installed by the daemon at runtime +(`services/runner/package.json` `runtimeAgentPins`, and baked in the image at +`docker/Dockerfile.gh:109`). It **is** present on this machine at +`~/.local/share/sandbox-agent/bin/agent_processes/codex/`, version 1.1.7 matching the pin. + +`@agentclientprotocol/codex-acp@1.1.7`: + +- **Zero matches** for `list_changed` or `listChanged` in its `dist/index.js`. +- MCP servers become **static Codex config at session creation**: `createSessionConfig(...)` at + `dist/index.js:26383` produces `"mcp_servers": {...}` at `:26409`, consumed only inside + `tryCreateSession` (`:28642`) — that is `session/new` / resume. +- It advertises `mcpCapabilities: { acp: false, http: true, sse: false }` (`:28497-28501`). No SSE + means no server-push channel even if the core supported it. +- The only "add an MCP server" API is a pre-start builder that mutates the `session/new` request + (`:21373` `withMcpServer`), not a live session. + +The Codex **Rust core** binary does contain `notifications/tools/list_changed` (9 byte offsets), +`rmcp` `ToolListChangedNotification` type names, and a human-readable `"MCP server tool list +changed"` string. Whether that is a live handler or an `rmcp` default no-op **could not be +determined** from a stripped binary. It does not change the verdict: codex-acp is the layer we talk +to, and it has no path to change a live session's tools. + +### Two further constraints the spike surfaced + +**1. Our own fingerprint evicts on a tool change anyway.** `configFingerprint` includes +`customTools` (`session-identity.ts:231`). So even a fully live-capable harness would not be reached +today; the pool evicts first. Any S2 rollout must remove `customTools` from the eviction fingerprint +in the same step. + +**2. Changing the MCP *server list* is a reopen for every harness, independent of S2.** The runner +has no API to change MCP servers on a live session: `mcpServers` appears only in session creation +(`environment.ts:1008`, built by `buildSessionMcpServers` at `:958`), and the `Session` class +(`sandbox-agent/dist/index.d.ts:3051-3076`) exposes `setModel`, `setMode`, `setConfigOption`, +`setThoughtLevel`, and `respondPermission` — nothing for MCP. The Claude ACP adapter enforces this +itself: `computeSessionFingerprint` hashes `{cwd, mcpServers}` (`acp-agent.js:56`) and on mismatch +tears the session down (`:2707-2722`, comment: "MCP servers reconfigured. Tear down the existing +session and recreate it"). The ACP protocol has no help either: `@agentclientprotocol/sdk@1.2.1` has +`available_commands_update` for slash commands but **no equivalent session-update for tools**. + +`list_changed` changes the tools *behind* an already-connected server (ours), so it does not trip the +adapter fingerprint. But a user adding their own MCP server is a reopen, always. + +### Summary for the adapter capability table + +``` +Pi: toolCatalog = "restart-runtime" TODAY (env var read once; no channel) + -> "apply-live" reachable: registerTool + setActiveTools are live APIs + caveat: add and hide only; true deregistration is impossible +Claude: toolCatalog = "reopen-session" TODAY (our shim never advertises listChanged) + -> "apply-live" reachable: Claude's client side is already built +Codex: toolCatalog = "reopen-session" (proven: codex-acp bakes MCP config at session/new) + +All harnesses: mcpServers = "reopen-session" (no live API anywhere in the stack) +All harnesses: activeSessionObservation = "next-turn" (in-turn timing unverified everywhere) +``` + +Work needed, in order: + +1. Remove `customTools` from `configFingerprint`, or nothing else is reachable. +2. **Pi**: write specs to a file instead of env; add an extension hook that re-reads it and calls + `registerTool` / `setActiveTools`. No Pi change needed. +3. **Claude**: advertise `capabilities: { tools: { listChanged: true } }` in both shims; make the + stdio shim reload its specs file and emit the notification. +4. **Claude, local only**: give the HTTP shim a real Streamable-HTTP SSE stream, or accept that local + runs stay `reopen-session` while Daytona runs go live. +5. Measure whether each refresh reaches the model in-turn or only at the next turn, and set + `activeSessionObservation` from the measurement, not from the mechanism. +6. **Codex**: leave at `reopen-session`. Revisit only if codex-acp gains a live MCP path. + +--- + +## Part 3 — characterization tests + +File: `services/runner/tests/unit/session-lifecycle-characterization.test.ts`. 15 tests, all +passing. They pin **today's** behavior. Two of the three blocks describe a defect. The tests are +written so that fixing the defect **breaks the test**, which is the point: the refactor must edit +them deliberately, and that edit is the record. + +### (a) A revision-id-only change evicts the parked session + +| Test | What it pins | +|---|---| +| a revision-ID-only change produces a different `configFingerprint` | The revision id is folded into environment identity (`session-identity.ts` ~250-258). | +| a revision-VERSION-only change produces a different fingerprint | `revision.version` is in the fingerprint too. | +| a draft-flag-only change produces a different fingerprint | `is_draft` is in the fingerprint too. | +| same revision gives the same fingerprint | Sanity. The fingerprint is stable, so the three tests above isolate the revision fields alone. | +| END TO END: committing a revision evicts and rebuilds an otherwise identical session | The full dispatch. Two turns, identical in every way except the revision id. `acquire` goes from 1 to 2, the warm environment is destroyed, and the destroy carries reason `compatibility-mismatch`. | + +The end-to-end test is the one that shows the product cost. The agent commits a revision, the +service sends the new revision id on the next turn, and a perfectly usable warm sandbox is thrown +away. It also feeds block (b), because it captures the teardown reason. + +### (b) Teardown maps `compatibility-mismatch` to delete, not stop + +| Test | What it pins | +|---|---| +| `compatibility-mismatch` => `delete` | The core mapping (`teardown.ts` ~23-37). | +| the reasons that DO park today | `clean-resumable`, `idle-expiry`, `capacity-eviction`, `shutdown-idle` all map to `stop`. | +| the reasons that delete today | `kill`, `failed-turn`, `aborted`, `compatibility-mismatch`, `shutdown-in-flight`. | +| there is no separate session/runtime/sandbox-incompatible reason yet | The `TeardownReason` union has exactly 9 members. This test fails the moment migration step 1 adds the new reasons, which makes the refactor visible. | +| the park default is on | With `parkCleanResumableTurns` false everything deletes. Confirms the default (true) is what the other tests characterize, so this is a real disposition and not a disabled flag. | + +The lifecycle doc warns against a blind one-line change mapping every `compatibility-mismatch` to +`stop`, because credentials and Pi runtime assets would survive in a stale daemon. These tests pin +the current mapping without endorsing either fix. + +### (c) Approval resume re-parks with the incoming request fingerprint + +This is the known stale-config bug. + +| Test | What it pins | +|---|---| +| the approval branch never compares the incoming config fingerprint at all | `server.ts` ~767-870. The idle branch checks `cfgFp !== existing.configFingerprint`; the approval branch does not. A resume with a changed config runs on the parked environment. | +| **THE BUG**: the re-park stamps the INCOMING fingerprint | `reparkOrEvict` (`server.ts` ~596-603, ~920) sets `configFingerprint: cfgFp` from the incoming request. The test asserts the re-parked fingerprint equals `configFingerprint(resume)` and differs from the one the environment was actually built with. | +| THE CONSEQUENCE: the next turn reuses an environment stamped with config it never applied | A third turn carrying the new config matches the stamped fingerprint and continues warm. | +| a model change across an approval is recorded even though `setModel` never ran | The sharpest form. The pool claims the environment runs `m2` when it was built with `m1` and never had `setModel` called. This is the exact inverse of the regression the migration's step 2 wants to add. | +| for contrast: the IDLE branch does compare and evicts | Proves the asymmetry is in the approval branch specifically, not in the fingerprint or the pool. | + +Why (a) and (c) interact: the commit the agent asks approval **for** is what changes the revision +id. So an approval reply routinely arrives with a different config than the park. Fixing (a) makes +that specific pairing harmless. It does **not** fix (c): any facet that really does matter (model, +skills, tools) still gets stamped without being applied. The tests say so in their assertion +messages. + +--- + +## Implicit decisions I had to make + +1. **The codec emits snake case.** The value crosses into pydantic, not onto the `/run` wire. If + the commit tool ends up normalizing case server-side, this should change. +2. **`allow_executable_files` is derived from the bits present.** A folder cannot state a policy. See + the gap table. This needs a product decision. +3. **A binary file is dropped, not encoded.** No base64 into a text field. +4. **One oversized file warns; an oversized folder fails.** Asymmetric on purpose. +5. **The `value_from` walk is narrow.** Only `delta.operations[i]`. A broad search is a capability, + not a convenience. +6. **The codec takes a `WorkspaceReader`, not a `RelayHost`.** `RelayHost` cannot answer the + questions the codec asks, and the Daytona path needs a different implementation entirely. +7. **`sandboxWorkspaceReader` throws instead of shipping.** A wrong Daytona reader would silently + drop the symlink defense. A throw is honest. +8. **`name` falls back to the folder basename.** A downloaded skill folder normally matches its + skill name. Explicit frontmatter still wins. +9. **The frontmatter parser is hand-rolled.** No YAML dependency for two scalar fields. +10. **The characterization tests are a new file, not additions to the existing keepalive files.** + They document defects and will need deliberate editing. Mixing them into files that assert + correct behavior would blur that. +11. **The Part 3 tests use their own fake engine** rather than the existing helpers, because they + need to record the teardown **reason** each destroy carries, which the existing helpers do not. + +## Open questions + +1. **Who resolves `value_from` for a non-approval run?** If a run has a permissive permission policy + and no gate is raised, there is no approval card and no natural freeze point. The fallback is + inline resolution at execution. Is that acceptable, or should a `value_from` operation always + force a gate? +2. **What does the approval card render for a large skill?** A body diff plus a file manifest with + sizes and digests can be long. What is the truncation rule, and does the digest cover the + truncated view or the full bytes? +3. **Binary assets in skill folders.** Is dropping them acceptable for v1, or does this block the + feature for real downloaded skills? The `uri`-variant path in the SDK model is the eventual fix, + but it is not built. +4. **What is the workspace root exactly?** The spike used `plan.workspace.cwd`. On Daytona that is a + geesefs-mounted durable directory. Should `value_from` be able to reach anywhere under it, or + only under a designated subdirectory? Reaching anywhere means the model can commit any file the + sandbox can read into a revision. +5. **Pi tool removal.** Pi can add and hide, but never truly deregister. Is "hidden from the model" + an acceptable definition of removed, given the tool remains in the registry and could be + re-activated? +6. **Does Claude's tool refresh reach the model mid-turn?** The cache refresh and the live state + splice are proven; the timing is not. Same question for Pi's `refreshTools`. This decides + `activeSessionObservation: "immediate"` versus `"next-turn"`. +7. **Does the HTTP shim justify SSE?** If Daytona is the strategic path, the local HTTP shim may + simply stay `reopen-session` rather than grow a streaming transport. +8. **Codex Rust core.** The `"MCP server tool list changed"` string suggests a handler exists. Worth + one hour with a debug build or the codex source before writing Codex off permanently. +9. **Should `remove_item` for a skill also clean the workspace?** Out of scope here, but the + lifecycle doc's warning about `prepareWorkspace` not removing vanished skill directories + (`workspace.ts:56`) is the same problem seen from the other end. diff --git a/docs/design/agent-config-editing/status.md b/docs/design/agent-config-editing/status.md index db6ba7a9f8..92c3bb8013 100644 --- a/docs/design/agent-config-editing/status.md +++ b/docs/design/agent-config-editing/status.md @@ -1,15 +1,21 @@ # Status -Updated: 2026-08-04, by team-lead. +Updated: 2026-08-04 evening, by team-lead. ## Where we are -Phase 1 (spikes). The planning workspace is being committed and the draft PR opened. -Both spike teammates are running in worktrees: +Phase 1 exit review. Both spikes are complete and green: -- engine-spike: prototyping the change-set engine (task #2). -- runner-spike: value_from proof, tools-discovery verdict, lifecycle characterization - tests (task #3). +- engine-spike: the pure engine works, 120 tests, legacy parity proven against the real + service code. Report: `spikes/engine-spike.md`. +- runner-spike: value_from proven end to end (34 tests), the tools-discovery verdict is + in (Pi and Claude are blocked by our own delivery, not by the harness; Codex needs a + session reopen), and 15 characterization tests pin today's lifecycle behavior. + Report: `spikes/runner-spike.md`. + +The consolidated decisions are in `decisions.md`. Seven product calls wait on Mahmoud +(listed there). A Codex design review of the finalized design is running. Slices start +when it returns. Draft PR: https://github.com/Agenta-AI/agenta/pull/5733. ## Decisions taken (4 August review with Mahmoud) From bc0003eca2883240033b8545307c8d181aa8ef4d Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Tue, 4 Aug 2026 21:30:13 +0200 Subject: [PATCH 03/36] docs(design): NO-GO gate review saved; contract-writing phase before slices --- .../research/design-gate-review-codex.md | 200 ++++++++++++++++++ docs/design/agent-config-editing/status.md | 21 +- 2 files changed, 219 insertions(+), 2 deletions(-) create mode 100644 docs/design/agent-config-editing/research/design-gate-review-codex.md diff --git a/docs/design/agent-config-editing/research/design-gate-review-codex.md b/docs/design/agent-config-editing/research/design-gate-review-codex.md new file mode 100644 index 0000000000..ec6aca19fb --- /dev/null +++ b/docs/design/agent-config-editing/research/design-gate-review-codex.md @@ -0,0 +1,200 @@ +# Verdict + +**NO-GO. Do not start implementation from the current documents.** + +The ordered change-set core is viable. The design around it is not final. The largest blockers are: + +- `value_from` approval is not secure against the existing forged-relay path. +- The atomic commit and no-change response contracts are unspecified. +- `read_config`, draft behavior, and the builder tool’s editable scope remain undecided. +- The revised lifecycle matrix lacks an applied-generation acknowledgement. +- The slice plan understates dependencies and combines several high-risk migrations. + +The prototype test counts do not clear this gate. Both prototypes predate several accepted amendments, and the runner prototype does not integrate with approval, relay, parking, or Daytona. + +# Must fix before code + +1. **Produce one authoritative change-set contract.** + + [decisions.md](/home/mahmoud/code/agenta-2/docs/design/agent-config-editing/decisions.md:8) accepts D33, which says the engine has no warning channel, then says the engine returns warnings. The interface allows `value_from` only on `set`, `add_item`, and `replace_item,` while the prototype also allows `merge` ([change_set.py](/home/mahmoud/code/agenta-2/.claude/worktrees/agent-a2a2adaa5d154d454/api/oss/src/core/workflows/change_set.py:102)). `match_mode` and parent creation are absent from the prototype. + + Update [change-set-interface-codex.md](/home/mahmoud/code/agenta-2/docs/design/agent-config-editing/research/change-set-interface-codex.md:128), [engine-spike.md](/home/mahmoud/code/agenta-2/docs/design/agent-config-editing/spikes/engine-spike.md:73), and `decisions.md` with exact schemas and semantics. + +2. **Specify the atomic commit transaction and the no-change wire response.** + + The design must say how one transaction locks the variant, reads the head, checks `base_revision_id`, applies and validates the change, compares the persisted canonical data, and inserts or returns the existing head. Today service resolution and DAO insertion are separate transactions. + + A warning plus “head id” is not compatible with the existing `WorkflowRevisionResponse`, which expects a complete revision and drives playground refreshes ([models.py](/home/mahmoud/code/agenta-2/api/oss/src/apis/fastapi/workflows/models.py:387), [stream.py](/home/mahmoud/code/agenta-2/sdks/python/agenta/sdk/agents/adapters/vercel/stream.py:838)). Define: + + - 409 precedence over no-change, even if a stale operation happens to equal the new head. + - Validation and canonicalization before equality comparison. + - A typed response such as `status: committed | no_change`, the complete current revision, and structured warnings. + - No commit event or cache invalidation on no-change. + + Update the interface spec and [plan.md](/home/mahmoud/code/agenta-2/docs/design/agent-config-editing/plan.md:40). + +3. **Design `read_config`, draft behavior, editable scope, and `description` before their slices start.** + + Slice 2 is currently one sentence. The RFC still leaves the draft base and editable configuration scope open ([rfc.html](/home/mahmoud/code/agenta-2/docs/design/agent-config-editing/research/rfc.html:1030)). These are blockers: + + - A draft run can read the committed head while executing unsaved browser changes. + - The prototype’s commit policy is `allow_all`, which conflicts with R7’s server-owned-field protection. + - Partial-read addressing, output limits, exact-byte behavior, stale-head reads, and error shapes are undefined. + - R12 does not say where `description` lives or distinguish ephemeral call description from persisted revision description/message. + + Add a dedicated contract document and update `decisions.md`, `plan.md`, and the RFC. + +4. **Replace the `toolCallId` cache with a single-use execution authorization.** + + A frozen entry must bind at least: + + - Tool name. + - Tool-call id. + - Canonical original arguments or their digest. + - Frozen value and full-content digest. + - Tool-catalog generation. + - Expiry and consumed state. + + A missing entry for a gated call must fail closed. It must never trigger inline rereading. The existing non-Pi relay guard explicitly permits forged `ask` records ([relay-guard.ts](/home/mahmoud/code/agenta-2/.claude/worktrees/agent-ab45c024d7ac4f4ab/services/runner/src/engines/sandbox_agent/relay-guard.ts:14)). Therefore the proposed cache-miss fallback is exploitable. + + Update [runner-spike.md](/home/mahmoud/code/agenta-2/docs/design/agent-config-editing/spikes/runner-spike.md:112), `decisions.md`, and `plan.md`. + +5. **Make the workspace import boundary safe and lossless by default.** + + The current prototype: + + - Derives `allow_executable_files` from mode bits, converting a filesystem fact into a policy grant ([skill-codec.ts](/home/mahmoud/code/agenta-2/.claude/worktrees/agent-ab45c024d7ac4f4ab/services/runner/src/tools/skill-codec.ts:500)). + - Drops binary and oversized files while still committing a partial skill. + - Uses separate realpath, stat, and read calls, leaving a symlink/content TOCTOU window. + - Returns counts but not the promised manifest, per-file sizes/digests, or diff ([value-from.ts](/home/mahmoud/code/agenta-2/.claude/worktrees/agent-ab45c024d7ac4f4ab/services/runner/src/tools/value-from.ts:34)). + - Has no Daytona implementation. + + Use a designated import root, reject unsupported files by default, keep executable permission explicit and default-deny, snapshot the bytes atomically enough for the threat model, and digest the bytes actually executed. Define safe Daytona filename framing, timeouts, cancellation, aggregate memory limits, and cleanup. + +6. **Restore the lifecycle design’s applied-state invariant in the revised matrix.** + + Apply-live is acceptable only when the adapter can acknowledge generation N. Emitting a Pi hook or Claude notification does not prove the model installed the new catalog. The runner must not advance applied state until it has an acknowledgement or must fall back to reopen. + + Also require one generation across the model-visible catalog and the turn execution plan. Removing `customTools` from the fingerprint before that is a stale-tool bug. The current turn still reads execution specs from environment state ([runner-lifecycle-codex.md](/home/mahmoud/code/agenta-2/docs/design/agent-config-editing/research/runner-lifecycle-codex.md:372)). + + Update `decisions.md`, [runner-lifecycle-codex.md](/home/mahmoud/code/agenta-2/docs/design/agent-config-editing/research/runner-lifecycle-codex.md:230), and `plan.md`. + +7. **Rewrite the slice plan.** + + The claim that slices 1 to 4 and 5 to 7 touch disjoint files is false. Slice 3 changes runner approval, relay, parked state, workspace I/O, and frontend code, overlapping slices 5 to 7. + + At minimum: + + - Do not expose ordered commits until `read_config` and retry behavior exist. + - Split slice 1 into pure engine/schema and transactional wrapper/catalog enablement. + - Split slice 3 into source codec, authorization/freeze integration, and approval UI. + - Split slice 7 into lifecycle extraction, low-risk workspace/model routes, tool-catalog routes, MCP reopen/continuity, and credential/provider reconciliation. + - Add lifecycle step 9. “Daytona keys never rebuild” requires the Daytona creation-identity split that [plan.md](/home/mahmoud/code/agenta-2/docs/design/agent-config-editing/plan.md:46) currently omits. + +8. **Add rollout and test gates, not only per-slice unit tests.** + + Update `plan.md` with: + + - Mixed-version API, SDK, catalog, and runner deployment order plus a kill switch. + - Legacy DTO compatibility. Applying `extra="forbid"` to legacy deltas changes previously ignored input. + - A two-writer database race test proving one commit and one 409. + - No-change versus concurrent-head tests. + - Golden schemas for canonical tool identity across Python and TypeScript. + - Local and Daytona `value_from` tests covering mutation after approval, forged records, same-id argument substitution, timeout, denial, TTL expiry, cold resume, symlink races, and memory limits. + - Real per-version harness tests for add, replace, remove, reopen, and native-history preservation. + - Partial-reconciliation tests proving applied state never advances after a failed action. + +# Answers to the six questions + +## 1. Do the amendments hold? + +1. **Set auto-creates object parents:** Conditionally yes, but the contract is underspecified. Create only missing plain-string segments as `{}`. Never create through a selector. Existing scalar, list, or null parents must fail. Final validation remains mandatory. The stated “two operations” rationale is incorrect because the caller could set the absent bag as one object, though parent creation is still useful for narrow edits. + +2. **Overlap-aware counting:** Yes. `"aa"` in `"aaa"` is ambiguous and must return `text_not_unique`. Add complexity limits and tests. The current prototype still uses non-overlapping `str.count` ([change_set.py](/home/mahmoud/code/agenta-2/.claude/worktrees/agent-a2a2adaa5d154d454/api/oss/src/core/workflows/change_set.py:481)). + +3. **`match_mode` from day one:** Not as a required field. Adding an optional field later is already additive. If retained now, make it optional with default `exact`, validate `Literal["exact"]`, and make the engine dispatch it explicitly rather than ignore it. + +4. **No-change warning instead of revision:** The policy is correct. The response and transaction design are not. Return the complete current revision with an explicit no-change status and structured warning. Validate first, compare canonical persisted data, let stale-base 409 win, and emit no revision event. + +## 2. Holes in `value_from` + +Yes, four major ones: + +1. **Forged records:** `toolCallId` is correlation, not authorization. A forged non-Pi `ask` relay record currently passes. Bind and consume an exact execution authorization. + +2. **Double resolution:** Cache miss cannot mean “ungated.” For an `ask` call it must fail closed. Inline resolution is allowed only after the permission plan explicitly classifies the call as ungated/allowed. + +3. **Timeout and resource cleanup:** Resolution needs an abort signal, hard deadline, per-turn byte/source/gate limits, and cleanup on deny, expiry, eviction, failure, or cancellation. + +4. **Park and cold resume:** Live resume may retain the frozen value in `ParkedApproval`, separately from raw model args. Cold fallback must not reuse an approval keyed only to the same path. Either preserve the exact snapshot across the transition or invalidate the approval and raise a new gate. The current server deliberately falls back cold after approval mismatch or failure ([server.ts](/home/mahmoud/code/agenta-2/.claude/worktrees/agent-ab45c024d7ac4f4ab/services/runner/src/server.ts:856)). + +Do not persist the full materialized value as ordinary tool args. The current interaction path persists args, which would recreate the large-payload problem and unnecessarily duplicate content. + +## 3. Does the revised adapter matrix create correctness gaps? + +Yes: + +- `customTools` cannot leave the fingerprint until catalog reconciliation and the fresh turn execution plan are atomic. +- Pi “hidden” removal must also remove the runner execution binding. Hidden is visibility, not revocation. +- Pi and Claude need generation acknowledgement before applied state advances. +- Claude capability is transport-specific: Daytona stdio may apply live, local HTTP reopens. The capability key must include adapter version and transport/provider. +- Codex reopen must verify native history actually loaded before preserving continuity. +- “MCP servers reopen everywhere” is inaccurate for Pi, which has no MCP client in the tested version. Mark it unsupported or define a real delivery mechanism. +- The runtime lifecycle remains necessary for older adapters, failed live application, provider settings, credentials, and harness files. +- All reconciliation must happen between turns. Never mutate a catalog while an approval-suspended prompt is still in flight. + +The original desired-state/applied-state architecture still holds. The spike changes individual routes, not that architecture. + +## 4. Slice ordering and size + +The ordering misses these dependencies: + +- Ordered operations should not become model-visible before `read_config`. +- Legacy base defaulting requires runner work despite slice 1 being described as API/frontend only. +- `value_from` overlaps the approval and parked-state code later refactored in slices 5 to 7. +- Live tool routes depend on fresh per-turn execution plans and generation tracking, not only fingerprint removal. +- Daytona credential refresh depends on the missing provider creation-identity split. + +Slices 1, 3, and 7 are too large. Slice 7 is especially unreviewable: it combines structural extraction, deletion-aware workspace refresh, three harness routes, MCP reopen, history continuity, credential delivery, and provider identity. + +## 5. The seven product calls + +| Call | My answer | Design blocker? | +|---|---|---| +| Storage normalization | Do not normalize every configuration string. Preserve exact bytes in v1; consider narrowly scoped normalization for explicit prose fields later. | No | +| Existing duplicate names | Enforce that a commit introduces no new duplicate and repairs any touched collection; warn on untouched legacy duplicates. Define ancestor/full-data writes explicitly. | **Yes** | +| Embedded skills | Accept unaddressable embeds in v1, but amend R3 and document whole-list fallback. | No, once scope is corrected | +| Ungated `value_from` | Force a gate in v1. Tool permission and workspace-read/persistence capability are different policies. | **Yes, security** | +| Binary files | Reject the whole source unless the caller explicitly opts into omission. Do not silently commit a partial skill. | **Yes, data integrity** | +| Workspace reach | Restrict to a designated import/staging root. A manifest is not sufficient protection against committing secrets. | **Yes, security** | +| Pi removal by hiding | Accept only if the active set becomes exactly desired and the runner rejects execution through the old binding. Otherwise restart the runtime. | Product preference, but the execution invariant is blocking | + +There is also an eighth missing call: never derive `allow_executable_files` from file mode bits. Require explicit policy and keep default-deny. + +## 6. What is missing entirely? + +- A decision for draft runs and unsaved browser edits. +- A builder-tool scope policy protecting URI, schemas, flags, permissions, harness choice, and other server/product-owned fields. +- A complete `read_config` contract. +- A complete R12 `description` contract. +- The database transaction seam needed to perform application validation while holding the head lock. +- Legacy strict-schema migration and mixed-version rollout. +- Capability negotiation and rollback for API/catalog/runner version skew. +- Frozen-content digest and approval truncation semantics. +- Source snapshot/TOCTOU protection. +- Approval-snapshot memory accounting and expiry. +- Cross-language canonical tool-key fixtures. +- Full-data commit and ancestor-operation uniqueness rules. +- Provider-switch eviction and immutable image/snapshot rebuild behavior. +- Shadow-router logging rules that prohibit credential/config-content leakage. +- A decision on storing the authored operations/diff for audit. The RFC promises this, but the plan does not implement or explicitly drop it. +- Real harness and Daytona acceptance tests. Static bundle inspection is not sufficient for apply-live correctness. + +# Nice-to-haves + +- Add a stable raw key for embedded skills. +- Add a blob `uri` variant for binary skill assets. +- Add Streamable HTTP/SSE to the local Claude shim after v1. +- Revisit Codex apply-live only when the ACP adapter exposes it. +- Add property-based tests for target resolution, operation ordering, and text overlap counting. +- Record per-route reconciliation metrics and generation mismatches so the rollout can fall back before users see stale state. diff --git a/docs/design/agent-config-editing/status.md b/docs/design/agent-config-editing/status.md index 92c3bb8013..8a80ff35f9 100644 --- a/docs/design/agent-config-editing/status.md +++ b/docs/design/agent-config-editing/status.md @@ -14,8 +14,25 @@ Phase 1 exit review. Both spikes are complete and green: Report: `spikes/runner-spike.md`. The consolidated decisions are in `decisions.md`. Seven product calls wait on Mahmoud -(listed there). A Codex design review of the finalized design is running. Slices start -when it returns. Draft PR: https://github.com/Agenta-AI/agenta/pull/5733. +(listed there). Draft PR: https://github.com/Agenta-AI/agenta/pull/5733. + +## Design gate: NO-GO (first pass) + +The Codex design gate review (`research/design-gate-review-codex.md`) returned NO-GO +with eight must-fix items before implementation. The largest: the value_from approval +flow needs a single-use execution authorization (the toolCallId cache is forgeable via +the relay directory); the commit transaction and the no-change response are +unspecified; read_config, the editable-scope policy, and the description field need +real contracts; live tool routes need an applied-generation acknowledgement; the slice +plan understates dependencies and slices 1, 3, 7 are too big. + +Response: the team writes the missing contracts before any slice starts. engine-spike +owns the change-set contract, the commit transaction and response, and the read_config +contract. runner-spike owns the execution authorization, the workspace import +boundary, and the corrected adapter matrix. Second gate review after that. Fail-closed +defaults adopted meanwhile: value_from always gates, imports come from a designated +root, unsupported files reject the source unless the caller opts into omission, +executable policy is explicit and default-deny, no blanket text normalization. ## Decisions taken (4 August review with Mahmoud) From 6549f381f9643420c318231d9c6688d39b7acdd1 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Tue, 4 Aug 2026 21:42:09 +0200 Subject: [PATCH 04/36] docs(design): six implementation contracts + arbitration and open calls change-set, commit-transaction, read-config (engine-spike); execution-authorization, workspace-import, adapter-matrix (runner-spike). The value_from schema conflict is arbitrated; twelve product calls open. --- .../contracts/adapter-matrix.md | 402 +++++++++++ .../contracts/change-set.md | 634 ++++++++++++++++++ .../contracts/commit-transaction.md | 313 +++++++++ .../contracts/execution-authorization.md | 396 +++++++++++ .../contracts/read-config.md | 384 +++++++++++ .../contracts/workspace-import.md | 556 +++++++++++++++ docs/design/agent-config-editing/decisions.md | 37 +- docs/design/agent-config-editing/status.md | 9 +- 8 files changed, 2728 insertions(+), 3 deletions(-) create mode 100644 docs/design/agent-config-editing/contracts/adapter-matrix.md create mode 100644 docs/design/agent-config-editing/contracts/change-set.md create mode 100644 docs/design/agent-config-editing/contracts/commit-transaction.md create mode 100644 docs/design/agent-config-editing/contracts/execution-authorization.md create mode 100644 docs/design/agent-config-editing/contracts/read-config.md create mode 100644 docs/design/agent-config-editing/contracts/workspace-import.md diff --git a/docs/design/agent-config-editing/contracts/adapter-matrix.md b/docs/design/agent-config-editing/contracts/adapter-matrix.md new file mode 100644 index 0000000000..5701af2da1 --- /dev/null +++ b/docs/design/agent-config-editing/contracts/adapter-matrix.md @@ -0,0 +1,402 @@ +# Contract: the harness reconciliation matrix + +Status: proposed. This contract answers must-fix item 6 and answer section 3 of +`research/design-gate-review-codex.md`. + +This contract corrects the matrix in `spikes/runner-spike.md` and in the runner block of +`decisions.md`. The spike proved which mechanisms exist. It did not prove that a mechanism +installs a new catalog. This contract adds the missing invariant and fixes four rows. + +## 1. The applied-generation acknowledgement invariant + +### 1.1 The rule + +**The runner advances applied state only when the adapter acknowledges the new generation. In +every other case the runner falls back to reopen.** + +Sending a Pi hook call does not prove Pi installed the catalog. Emitting an MCP +`notifications/tools/list_changed` does not prove Claude fetched the new list. Both are messages +the runner sends. Neither is a reply. + +This restores the core rule of `research/runner-lifecycle-codex.md`: requests describe desired +state, environments own applied state, and applied state is committed only from a successful +result. + +### 1.2 What counts as an acknowledgement + +An acknowledgement must be an observation the runner makes, not an action the runner takes. It +must identify the generation it confirms. + +| Acknowledgement | Strength | +|---|---| +| The adapter returns the installed catalog, and the runner compares it to the desired catalog. | Strong. Preferred. | +| The adapter returns a generation token the runner supplied. | Strong. | +| The harness issues a `tools/list` that the runner answers, and the runner records which generation it served. | Adequate. It proves the client refetched. | +| The runner's own send succeeded. | Not an acknowledgement. Never sufficient. | +| A log line in the harness. | Not an acknowledgement. Not observable by the runner. | + +### 1.3 The reconciliation procedure + +Every live catalog route follows the same five steps. + +1. Build the desired catalog. Compute its generation identifier. +2. Apply the harness-specific mechanism. +3. Wait for an acknowledgement, bounded by a deadline. +4. On acknowledgement, commit the new generation to applied state. +5. On timeout, on a mismatch, or on an error, do not commit. Escalate to reopen. If reopen is + not available, escalate to rebuild. + +Step 5 must never leave applied state partly advanced. A failed acknowledgement leaves the +environment recorded at generation N, which is the truth. + +Acknowledgement deadline: 5 seconds. It runs between turns, so it delays the next turn and never +interrupts one. + +### 1.4 Reconciliation happens between turns only + +The runner must never change a catalog while a turn is running. It must never change a catalog +while an approval-suspended prompt waits. + +An approval-suspended prompt is an in-flight turn. Its tool call was gated under one catalog. +Changing the catalog under it would split one logical turn across two configurations. The +execution authorization contract already fails such a call closed, through its +`catalogGeneration` binding. That is a safety net, not a licence to reconcile mid-turn. + +So a configuration change that arrives during an approval park is deferred. The runner records +it as a pending delta. It applies it after the suspended prompt finishes, before the next +ordinary turn. + +Two classes are exempt and must act at once, or fail closed: a permission tightening, and a +credential revocation. Neither is an ordinary configuration change. + +## 2. One generation across the catalog and the execution plan + +### 2.1 The rule + +**The model-visible tool catalog and the turn's tool execution plan carry one generation +identifier. A turn must never advertise generation N while the relay executes generation N+1.** + +### 2.2 Why this is not true today + +Read `services/runner/src/engines/sandbox_agent/run-turn.ts` around line 822. The relay is +started with three inputs from two different sources: + +- `plan.tools.toolSpecs`, which comes from the environment's stored run plan, built at acquire + time from the acquiring request; +- `request.toolCallback` and `request.runContext`, which come from the incoming request. + +So the relay already mixes acquire-time tool specs with turn-time callback and context. The +lifecycle document flags this. The split must become atomic before any live catalog route ships. + +### 2.3 The two objects + +The runner splits tools into two objects that share one generation. + +| Object | Holds | Consumer | +|---|---|---| +| `ToolCatalogManifest` | Name, description, input schema, read-only hint, permission. Public metadata only. | The harness, through the shim or the extension. | +| `ToolExecutionPlan` | Callback endpoint and authorization, call descriptors, context bindings, gateway references, timeouts, client-tool relay bindings. | The runner's relay and dispatch paths. | + +`services/runner/src/engines/sandbox_agent/tools/public-spec.ts` already separates public from +private metadata. That is the seam to build on. + +Both objects carry `catalogGeneration`. The turn runner receives both as one unit, freshly built +from the incoming request. It must not read either from `env.plan`. + +### 2.4 `customTools` and the fingerprint + +**`customTools` leaves `configFingerprint` only in the same change that makes the catalog and +the execution plan atomic.** + +`customTools` is in the fingerprint today. See +`services/runner/src/engines/sandbox_agent/session-identity.ts` line 231. So a tool-list change +evicts the warm session, and the stale-specs problem never surfaces. + +Removing `customTools` first would let a warm session continue with a changed tool list while the +relay still executes the acquire-time specs. That is a stale-tool bug, and it would be a security +bug whenever a removed tool stays executable. + +The order is therefore fixed: + +1. Build `ToolCatalogManifest` and `ToolExecutionPlan` with one generation. +2. Make `runTurn` take both from the incoming request. +3. Add the acknowledgement mechanism for the target harness. +4. Only then remove `customTools` from `configFingerprint`. + +Steps 1 to 3 change no reuse behavior. Step 4 is the only behavior change, and by then the +foundation exists. + +## 3. The corrected matrix + +Capability is keyed by harness, adapter version, **and transport or provider**. A single value +per harness is wrong. + +### 3.1 Tool catalog + +| Harness | Transport | Today | Target | Acknowledgement | +|---|---|---|---|---| +| Pi | Extension, local | `restart-runtime` | `apply-live` | The hook returns the installed active-tool set. The runner compares it to the desired set. | +| Pi | Extension, Daytona | `restart-runtime` | `apply-live` | Same. | +| Claude | stdio shim, Daytona | `reopen-session` | `apply-live` | The shim serves a `tools/list` after the notification and records the generation it served. | +| Claude | HTTP shim, local | `reopen-session` | `reopen-session` in v1 | Not applicable. | +| Codex | stdio or HTTP shim | `reopen-session` | `reopen-session` | Not applicable. | + +### 3.2 Model, mode, instructions, skills, harness files + +These rows are unchanged from `research/runner-lifecycle-codex.md`, with one addition: the +acknowledgement invariant applies to `setModel` and `setConfigOption` too. A model change is +committed to applied state only when the call returns success for the requested model. This is +the step-2 regression the migration plan already names. + +### 3.3 MCP servers + +| Harness | Value | Reason | +|---|---|---| +| Pi | `unsupported` | Not `reopen-session`. See section 5. | +| Claude | `reopen-session` | See section 6.3. | +| Codex | `reopen-session` | See section 7. | + +## 4. Pi + +### 4.1 What the spike proved + +`registerTool` is not load-time only. It calls `runtime.refreshTools()` +(`pi-coding-agent/dist/core/extensions/loader.js:184-191`), which is bound to a live +implementation (`dist/core/extensions/runner.js:167`, `dist/core/agent-session.js:1859`). The +refresh rebuilds the registry and auto-activates any name not previously present +(`agent-session.js:1979-1986`). The public extension interface exposes `getAllTools`, +`getActiveTools`, and `setActiveTools` (`dist/core/extensions/types.d.ts:917-921`). + +What blocks Pi today is delivery. Specs ride `AGENTA_AGENT_TOOLS_PUBLIC_SPECS`, a process +environment variable read once (`services/runner/src/extensions/agenta.ts:250`, set at +`services/runner/src/engines/sandbox_agent/pi-assets.ts:394`). An environment variable cannot +change on a running process. + +The fix is a file plus a hook. The runner writes the specs to a file. The extension re-reads it +and calls `registerTool` and `setActiveTools`. + +### 4.2 Removal is hidden, and hidden is not enough + +Pi has no deregister call. The only unregister in the interface is `unregisterProvider`, for +model providers. So a removed tool can only be hidden from the active set. + +**Hiding is visibility, not revocation.** The tool stays in the registry. The runner still holds +its execution binding. A prompt-injected model that names the hidden tool directly, or a forged +relay record that names it, would still reach the runner's dispatch path. + +So Pi removal has two required halves, and both must succeed: + +1. `setActiveTools` produces an active set exactly equal to the desired set. Not a superset. +2. **The runner removes the tool from `ToolExecutionPlan`.** The relay must refuse a call for a + tool absent from the current generation's execution plan, with a deny reason. + +Half 2 is the load-bearing half. Half 1 is user experience. The gate review states this as a +blocking execution invariant, and this contract adopts it. + +If either half cannot be confirmed, Pi removal escalates to `restart-runtime`. + +### 4.3 Pi acknowledgement + +The extension hook returns the result of `getActiveTools()` after the refresh. The runner +compares that set to the desired active set. Equality acknowledges the generation. Any +difference fails the reconciliation and escalates to restart. + +The return path needs a channel. The extension can write the acknowledgement into the relay +directory, which the runner already polls. Design it as an ordinary relay record with a reserved +name, so no new transport is needed. + +### 4.4 Pi and MCP + +Pi in the tested version has no MCP client. There is no `*mcp*` module in its distribution and no +`list_changed` anywhere in its tree. The runner also short-circuits MCP for Pi +(`services/runner/src/engines/sandbox_agent/mcp.ts:373`). + +So a user MCP server cannot be delivered to Pi at all. The matrix value is `unsupported`, not +`reopen-session`. `reopen-session` implies that reopening would deliver it. Reopening delivers +nothing. + +The product consequence must be stated in the user interface: a user who adds an MCP server to a +Pi agent gets nothing. Either the interface refuses the combination, or the runner returns a +clear capability error. Silently accepting the configuration is the worst option. + +## 5. Claude + +### 5.1 What the spike proved + +The shipped Claude binary registers a `tools/list_changed` handler, gated on the server +advertising `capabilities.tools.listChanged`. The handler invalidates the cached tool list, +refetches it, splices the result into live state, and logs "Received tools/list_changed +notification, refreshing tools". It has a documented failure path that keeps the previous tool +set when the refetch fails. + +So Claude's client side is built. Our shims are the blocker. Both advertise +`capabilities: { tools: {} }` with no `listChanged` +(`services/runner/src/tools/tool-mcp-http.ts:124`, +`services/runner/src/tools/tool-mcp-stdio.ts:183`). + +### 5.2 Capability is keyed by transport + +The two shims are not equivalent, and one capability value for Claude would be wrong. + +**Daytona, stdio shim.** The transport is bidirectional. The shim owns stdout for the life of +the session (`tool-mcp-stdio.ts:291-333`). It can write an unsolicited notification line. It +must also learn the new specs, because it loads them once at start. Target: `apply-live`. + +**Local, HTTP shim.** The transport is stateless JSON. The shim answers every non-POST verb with +405 (`tool-mcp-http.ts:366-370`). The header comment explains why: no SSE, no session id, no +streaming. The MCP server-to-client notification channel is the GET SSE stream, and there is +none. Target: `reopen-session` in v1. Adding Streamable HTTP with SSE is a nice-to-have, and the +gate review agrees. + +The capability key is therefore `{harness, adapterVersion, transport, provider}`. A single +`claude: apply-live` entry would make local runs silently stale. + +### 5.3 Claude acknowledgement + +The runner does not see Claude's internal refresh. It sees the shim. + +So the shim is the observer. After it emits the notification, it waits for the client's +`tools/list`. It records which generation it served. It reports that back to the runner over the +relay directory, as in section 4.3. + +No `tools/list` inside the deadline means no acknowledgement. The runner escalates to reopen. + +### 5.4 Claude MCP servers stay reopen + +The Claude ACP adapter enforces this itself. `computeSessionFingerprint` hashes `{cwd, +mcpServers}` (`claude-agent-acp/dist/index.js`, `acp-agent.js:56`), and a mismatch tears the +session down and recreates it (`acp-agent.js:2707-2722`). + +A `list_changed` notification does not change the MCP server list, so it does not trip this +fingerprint. The two mechanisms do not conflict. + +## 6. Codex + +### 6.1 Reopen, and why + +`@agentclientprotocol/codex-acp@1.1.7` has zero occurrences of `list_changed` or `listChanged`. +MCP servers become static Codex configuration at session creation: `createSessionConfig` emits +`"mcp_servers"`, consumed only inside `tryCreateSession`. The adapter advertises +`mcpCapabilities: { acp: false, http: true, sse: false }`. No SSE means no server-push channel. +The only "add an MCP server" call is a pre-start builder that mutates the `session/new` request. + +The Codex Rust core binary does contain `notifications/tools/list_changed` and an +`rmcp` tool-list-changed type. Whether that is a live handler or a library default could not be +determined from a stripped binary. It does not change the verdict, because codex-acp is the +layer the runner talks to and it has no live path. + +Codex stays `reopen-session` for both the tool catalog and MCP servers. + +### 6.2 Reopen must verify native history + +A reopen preserves continuity only if the native conversation actually loaded. The runner must +verify it, not assume it. + +Today the check is an identifier comparison. Read +`services/runner/src/engines/sandbox_agent/environment.ts` around line 1061: +`loadedFromContinuity = environment.session.agentSessionId === priorAgentSessionId`. + +That proves the adapter accepted the identifier. It does not prove the adapter replayed the +turns. A `session/load` that succeeds at the transport layer and loads no history would set this +flag to true. + +So a reopen for a configuration change must add a positive check before it claims continuity. +Two options, in order of preference: + +1. Read back the loaded conversation length or its last message identifier from the adapter, and + compare it to the runner's own record. +2. If the adapter exposes nothing, treat the reopen as a continuity loss and replay the + conversation, exactly as a cold turn does. + +A reopen that cannot verify history must not report continuity to the user. Silent history loss +is worse than a slower turn. + +This obligation is not Codex-specific. It applies to every reopen. It is written here because +Codex is the harness whose only route is reopen. + +## 7. The runtime lifecycle stays + +The spike's per-harness routes do not remove the need for a runtime and daemon lifecycle between +the harness session and the sandbox. It is still required for: + +- older adapter versions with no live route; +- any live application that fails its acknowledgement; +- provider settings and process environment; +- model and MCP credentials, which are create-time on Daytona today; +- harness configuration files, which are opaque and can encode startup and permission behavior; +- Pi tool removal, when the two halves in section 4.2 cannot both be confirmed. + +The desired-state and applied-state architecture in `research/runner-lifecycle-codex.md` is +unchanged. This contract changes individual routes inside it. + +## 8. Rollout order + +Each step ships alone. No step changes reuse behavior until step 5. + +1. Split tools into `ToolCatalogManifest` and `ToolExecutionPlan`. One generation. No behavior + change. +2. Make `runTurn` build both from the incoming request. Remove the `env.plan` read at + `run-turn.ts:822`. No behavior change. +3. Add the acknowledgement channel over the relay directory. No behavior change. +4. Ship the Pi specs file and the extension hook, plus the Claude stdio shim capability and + notification. Route both to reopen still, and log the acknowledgement. This is shadow mode. +5. Compare the shadow logs. Flip Pi and Claude-on-Daytona to `apply-live`. Remove `customTools` + from `configFingerprint` in the same change. +6. Measure observation timing. Set `activeSessionObservation` from the measurement. + +Metrics for step 5: per-route reconciliation attempts, acknowledgement successes, timeouts, and +generation mismatches. The rollout must be able to fall back before users see a stale catalog. + +## 9. Test obligations + +**Acknowledgement.** +- A live route whose acknowledgement times out leaves applied state at generation N and + escalates to reopen. +- A live route whose acknowledgement reports a different set leaves applied state at N. +- A successful acknowledgement advances applied state exactly once. +- Applied state never advances after a failed action. This is the partial-reconciliation test + the gate review requires. + +**One generation.** +- A turn's advertised catalog and its relay execution plan always report the same generation. +- A tool removed in generation N+1 cannot execute through the relay, even when the harness still + lists it. +- Removing `customTools` from the fingerprint without steps 1 to 3 must fail a test. Add the + test before the removal so it guards the order. + +**Pi.** +- Adding a tool mid-session makes it callable, and the hook acknowledges the exact active set. +- Removing a tool hides it AND makes the relay refuse it. +- A `setActiveTools` result that is a superset of the desired set escalates to restart. +- An MCP server configured for a Pi agent produces a clear capability error, not silence. + +**Claude.** +- Daytona stdio: a catalog change emits the notification, the shim serves a `tools/list`, and the + new tool is callable in the next turn. +- Local HTTP: the same change reopens the session. It must not claim apply-live. +- A capability lookup keyed only by harness must fail a test. The key must include transport. + +**Codex.** +- A catalog change reopens the session. +- A reopen that cannot verify native history reports continuity loss and replays. + +**Between turns.** +- A configuration change arriving during an approval park is deferred, not applied. +- A permission tightening arriving during an approval park is applied at once, or fails closed. + +Real harness tests are required for add, replace, remove, reopen, and native-history +preservation, per harness version. The gate review is explicit that static bundle inspection does +not prove apply-live correctness. The spike's evidence is static. It sets expectations; it does +not close the gate. + +## 10. Documents to update when this contract is accepted + +- `decisions.md`, the runner-spike tools-discovery block. Replace the four verdict lines with + section 3's matrix, and add the acknowledgement invariant. +- `decisions.md`, the `customTools` prerequisite line. Replace it with section 2.4's order. +- `decisions.md`, open product call 7. Pi removal by hiding is accepted only with section 4.2's + execution invariant. +- `research/runner-lifecycle-codex.md`, section 3's adapter matrix and the + `HarnessLifecycleCapabilities` type. The capability key needs transport and provider. +- `spikes/runner-spike.md`, the Part 2 verdict table and summary block. +- `plan.md`. Split slice 7 as must-fix item 7 requires, and adopt section 8's order. diff --git a/docs/design/agent-config-editing/contracts/change-set.md b/docs/design/agent-config-editing/contracts/change-set.md new file mode 100644 index 0000000000..4065b3cf5f --- /dev/null +++ b/docs/design/agent-config-editing/contracts/change-set.md @@ -0,0 +1,634 @@ +# Contract: the change set + +Status: proposed. It answers must-fix item 1 of the design gate review. +Owner: engine-spike. Date: 4 August 2026. + +This document is the one authoritative change-set contract. Where it disagrees with +`research/change-set-interface-codex.md`, `spikes/engine-spike.md`, or `decisions.md`, +this document wins. Section 12 lists the changes the prototype needs. + +## 1. Scope + +The change set describes a change to one workflow revision's data tree. It is data only. +It does not say where the base comes from. It does not say what happens after the change. + +Three layers use it: + +| Layer | Owns | +|---|---| +| The engine (`apply_change_set`) | Applies the change set to a base tree. Pure. No I/O. | +| The commit wrapper | The base check, the transaction, the response. See `commit-transaction.md`. | +| The runner | Turns `value_from` into an inline `value` before the API sees the call. | + +The engine never reads a path, never reads a database, and never writes one. + +## 2. The commit envelope + +The model sends this shape. The catalog binds `workflow_variant_id` from run context and +hides it from the model. + +```json +{ + "workflow_revision": { + "workflow_variant_id": "019c...", + "base_revision_id": "019c...", + "message": "Update the release QA instructions.", + "delta": { } + } +} +``` + +- `base_revision_id` is a precondition on the commit. It is not part of the delta. + It is required when `delta` uses the ordered form. `commit-transaction.md` section 8 + defines how a legacy call gets a default. +- `message` is the persisted commit message. +- The ephemeral per-call `description` is NOT in this envelope. See `read-config.md` + section 12. + +### 2.1 What the catalog advertises + +The model-visible schema is `_COMMIT_REVISION_INPUT_SCHEMA` in +`sdks/python/agenta/sdk/agents/platform/op_catalog.py`. It is closed +(`additionalProperties: false`) at every level. It must advertise exactly this set: + +| Field | Model-visible | Note | +|---|---|---| +| `workflow_revision.workflow_variant_id` | no | Bound from `$ctx.workflow.variant.id` and stripped. | +| `workflow_revision.base_revision_id` | yes | The runner defaults it only when absent. `commit-transaction.md` section 8. | +| `workflow_revision.message` | yes | | +| `workflow_revision.delta` | yes | The `oneOf` of section 3. | +| `...operations[].value_from.type` | yes | `"workspace"`. | +| `...operations[].value_from.path` | yes | Relative to the import root. | +| `...operations[].value_from.on_unsupported` | yes | `"reject"` (default) or `"omit"`. Section 5.1. | +| `...operations[].value_from.allow_executable_files` | yes | Boolean, default `false`. Section 5.1. | + +The two `value_from` policy fields must appear here, or the model cannot set them and the +defaults become the only reachable behavior. The runner strips the whole `value_from` +object during resolution, so neither field ever reaches the API. + +Nothing else is model-visible. `data`, `flags`, `name`, `description`, `tags`, and `meta` +stay off the model surface. `read-config.md` section 11 defines the second gate, the scope +policy, which closes the fields a `delta` could otherwise still reach. + +## 3. The delta: two forms, never mixed + +```json +{ + "oneOf": [ + { "$ref": "#/$defs/LegacyDelta" }, + { "$ref": "#/$defs/OrderedDelta" } + ] +} +``` + +### 3.1 LegacyDelta + +```json +{ + "type": "object", + "additionalProperties": false, + "properties": { + "set": { "type": "object" }, + "remove": { "type": "array", "items": { "type": "string", "minLength": 1 } } + }, + "anyOf": [ { "required": ["set"] }, { "required": ["remove"] } ] +} +``` + +Behavior does not change. `set` deep-merges with the dict-only recursion. Scalars and +lists replace. `remove` deletes dotted paths. A missing remove path stays a silent no-op. +The order is `set`, then `remove`. + +### 3.2 OrderedDelta + +```json +{ + "type": "object", + "additionalProperties": false, + "required": ["operations"], + "properties": { + "operations": { + "type": "array", + "minItems": 1, + "maxItems": 64, + "items": { "$ref": "#/$defs/Operation" } + } + } +} +``` + +`Operation` is a discriminated union on `operation`. Every member sets +`additionalProperties: false`. + +### 3.3 How the engine tells the forms apart + +A field counts as present only when its value is not null. A pydantic dump carries +`{"set": null, "remove": null, "operations": null}`, so a key test would classify every +ordered delta as legacy. + +- Both forms present: `invalid_delta`. +- No form present: `invalid_delta`. +- An unknown delta field: `invalid_delta`. + +## 4. The target grammar + +A target is a non-empty array of segments. + +```json +{ + "type": "array", + "minItems": 1, + "maxItems": 12, + "items": { + "oneOf": [ + { "type": "string", "minLength": 1 }, + { + "type": "object", + "additionalProperties": false, + "required": ["field", "key"], + "properties": { + "field": { "type": "string", "minLength": 1 }, + "key": { "type": "string", "minLength": 1 } + } + } + ] + } +} +``` + +A string segment addresses an object field. An object segment addresses one named entry +in the list at `field`. Example: + +```json +["parameters", "agent", {"field": "skills", "key": "release-qa"}, "body"] +``` + +### 4.1 Key fields per collection + +Only these four collections take a selector segment and item operations. + +| Collection | Key | +|---|---| +| `skills` | `name` | +| `mcps` | `name` | +| `files` | `path` | +| `tools` | the canonical tool name, section 4.2 | + +Any other list has no key. A selector on it gives `unkeyed_collection`. + +### 4.2 The canonical tool name + +One function, `item_key("tools", entry, allow_legacy_fallback)`. The SDK and the server +must share one implementation and one golden fixture set. + +| Tool `type` | Key | +|---|---| +| `gateway` | `name`. When `name` is absent and `allow_legacy_fallback` is true: `{integration}__{action}`. | +| `reference` | `name`, else `slug`. | +| `platform` | `op`. | +| `code`, `client`, `builtin` | `name`. | +| an `@ag.embed` object | none. The entry is not addressable. | + +`allow_legacy_fallback` is true when the engine READS the tree to find an entry. It is +false when the engine DERIVES the key of a value the caller supplies. So an old unnamed +gateway entry stays addressable, and a new one must carry an explicit `name`. + +An entry with no derivable key is skipped during a search. It never matches, and it never +collides. + +## 5. The seven operations + +### 5.1 Value sources + +| Operation | `value` | `value_from` | +|---|---|---| +| `set` | yes | yes | +| `merge` | yes | **no** | +| `remove` | no | no | +| `edit_text` | no | no | +| `add_item` | yes | yes | +| `replace_item` | yes | yes | +| `remove_item` | no | no | + +`merge` does not take `value_from`. A workspace source materializes a whole object, such +as a complete skill. A deep merge of a whole materialized object into an existing object +hides which fields survived. The result depends on the folder content, and the human who +approves the call cannot see it. `set` and `replace_item` state the intent clearly. + +A value-bearing operation carries exactly one of `value` and `value_from`. Both is +`invalid_operation`. Neither is `invalid_operation`. + +The engine refuses `value_from` with `source_invalid`. The runner must resolve it first. + +```json +{ + "type": "object", + "additionalProperties": false, + "required": ["type", "path"], + "properties": { + "type": { "const": "workspace" }, + "path": { "type": "string", "minLength": 1 }, + "on_unsupported": { "enum": ["reject", "omit"], "default": "reject" }, + "allow_executable_files": { "type": "boolean", "default": false } + } +} +``` + +`on_unsupported` and `allow_executable_files` are import-policy declarations. The +runner's import resolver consumes them. `workspace-import.md` section 4.2 defines +`on_unsupported` and its default, section 4.3 defines the `omit` opt-in, and section 5.2 +defines `allow_executable_files`. + +Three points fix their place: + +1. **They sit on the source, not on the operation.** One commit can import two folders and + give each one a different answer. A field on the operation could not do that. +2. **The engine never sees them.** The runner resolves `value_from` and then strips the + whole `value_from` object. It puts a plain inline `value` in place of it. So these two + fields never reach the API, and the engine surface does not grow. The engine still + refuses any `value_from` that survives, with `source_invalid`. +3. **The model must be able to write them.** This schema is model-facing. With + `additionalProperties: false` and no such fields, the defaults would be the only + reachable behavior. A skill folder with one binary asset would then be permanently + uncommittable. + +This resolves the conflict `workspace-import.md` section 11 raises against this section. + +### 5.2 The last target segment + +| Operation | Last segment | Addresses | +|---|---|---| +| `set`, `merge`, `remove`, `edit_text` | a string | an object field | +| `add_item` | a string | the list to append to | +| `replace_item`, `remove_item` | a selector | one named entry | + +A wrong tail is `invalid_operation`. This keeps one intent per verb. Without it, `set` on +a selector would do the work of `replace_item`. + +A selector may appear at any earlier position, for every operation. + +### 5.3 `set` + +```json +{ + "operation": "set", + "target": ["parameters", "agent", "llm", "model"], + "value": "anthropic/claude-opus-4" +} +``` + +Replaces the target value exactly. `value: null` writes null; it does not remove. + +**Parent creation.** `set` creates missing parents, under strict rules: + +1. It creates only plain-string segments, and only as `{}`. +2. It never creates through a selector. If any segment on the path is a selector, every + segment up to and including that selector must already resolve. A missing selector is + always `item_not_found` or `target_not_found`. +3. It never creates a list, and never creates a list entry. +4. An existing parent that is a scalar, a list, or null is `target_type_mismatch`. The + engine does not overwrite it with `{}`. +5. Final validation stays mandatory. Parent creation is a convenience, not a licence to + invent fields. The closed agent template rejects an invented path at validation. + +Example. With `harness: {"kind": "pi_agenta"}` in the base: + +- `set ["parameters","agent","harness","extras","system"] = "..."` creates `extras` as + `{}`, then writes `system`. It succeeds. +- `set ["parameters","agent","nope","x"] = 1` creates `nope` as `{}`, writes `x`, and + then fails final validation with `final_validation_failed`. +- `set ["uri","deeper"] = 1` fails with `target_type_mismatch`, because `uri` is a string. + +### 5.4 `merge` + +```json +{ + "operation": "merge", + "target": ["parameters", "agent", "llm"], + "value": {"extras": {"verbosity": "low"}} +} +``` + +Deep-merges an object with today's dict-only recursion. Nested dicts merge. Scalars and +lists replace. The target must exist and must be an object. `merge` never creates +parents. A missing target is `target_not_found`. A non-object target is +`target_type_mismatch`. A non-object `value` is `invalid_operation`. + +### 5.5 `remove` + +```json +{ "operation": "remove", "target": ["parameters", "agent", "llm", "extras"] } +``` + +Removes one object field. A missing field is `target_not_found`. This differs from the +legacy `remove`, which stays a silent no-op. + +### 5.6 `edit_text` + +```json +{ + "operation": "edit_text", + "target": ["parameters", "agent", "instructions", "agents_md"], + "match_mode": "exact", + "edits": [ + {"old_text": "Run the checks manually.", "new_text": "Run the release-qa skill."} + ] +} +``` + +```json +{ + "match_mode": { "type": "string", "enum": ["exact"], "default": "exact" }, + "edits": { + "type": "array", "minItems": 1, "maxItems": 32, + "items": { + "type": "object", "additionalProperties": false, + "required": ["old_text", "new_text"], + "properties": { + "old_text": { "type": "string", "minLength": 1, "maxLength": 20000 }, + "new_text": { "type": "string", "maxLength": 50000 } + } + } + } +} +``` + +`match_mode` is optional. The default is `exact`. Only `exact` is valid today. The engine +dispatches on the value through a table; it must not ignore the field. An unknown mode is +`invalid_operation`, even if the schema also rejects it. A later mode is then additive. + +The target must be a string. Anything else is `target_type_mismatch`. + +Rules, in order: + +1. `old_text` must not be empty. Empty gives `empty_old_text`. +2. Matching is exact on the code points. Nothing is normalized. The engine does not apply + NFKC or NFC. It does not fold smart quotes, dashes, or special spaces. It does not trim + trailing whitespace. It does not fold CRLF to LF. It does not strip a BOM. +3. `old_text` must occur exactly one time, counted with overlap. See section 5.6.1. +4. Every anchor matches the string as it was before this operation started. +5. Matches must not overlap. Adjacent matches are legal. +6. The engine applies the matches from the highest index to the lowest. +7. The batch must change the string. No change gives `no_change`. One edit that changes + nothing is fine, if another edit in the same batch changes something. +8. The batch is atomic. One bad edit leaves the string untouched. + +#### 5.6.1 Overlap-aware occurrence counting + +`str.count` counts without overlap. It reports one occurrence of `"aa"` in `"aaa"`. Two +start positions exist, so the anchor is ambiguous. The engine must count every start +position: + +```text +count = 0 +i = 0 +while True: + i = text.find(old_text, i) + if i < 0: break + count += 1 + i += 1 # advance by one, not by len(old_text) +``` + +Two or more positions give `text_not_unique` with `match_count`. Zero gives +`text_not_found`. + +#### 5.6.2 Work limits + +The scan costs O(n·m). The engine enforces limits before it scans: + +| Limit | Value | Error | +|---|---|---| +| target string length | 200 000 code points | `text_too_large` | +| `old_text` length | 20 000 code points | schema, then `invalid_operation` | +| edits per operation | 32 | schema, then `invalid_operation` | +| operations per delta | 64 | schema, then `invalid_delta` | + +The string limit matches `SkillFile.content` (`max_length=200_000`). + +### 5.7 `add_item` + +```json +{ + "operation": "add_item", + "target": ["parameters", "agent", "skills"], + "value": {"name": "pdf-tools", "description": "Make PDFs.", "body": "..."} +} +``` + +Appends one entry. The target must resolve to a list. The field name must be a keyed +collection, or the result is `unkeyed_collection`. The engine derives the key from the +value with `allow_legacy_fallback=false`. No key gives `item_key_undefined`. An existing +entry with that key gives `item_already_exists`. + +There is no position field. The new entry goes to the end. + +### 5.8 `replace_item` + +```json +{ + "operation": "replace_item", + "target": ["parameters", "agent", {"field": "skills", "key": "release-qa"}], + "value": {"name": "release-qa", "description": "...", "body": "..."} +} +``` + +Replaces one existing entry. A missing entry gives `item_not_found`. The key derived from +the value must equal the key in the target. A difference gives `invalid_operation`. A +rename is `remove_item` plus `add_item`. + +### 5.9 `remove_item` + +```json +{ + "operation": "remove_item", + "target": ["parameters", "agent", {"field": "tools", "key": "send-slack-message"}] +} +``` + +Removes one existing entry. A missing entry gives `item_not_found`. + +## 6. Application + +Operations run in array order. Each operation sees the result of the operations before +it. The first failing operation aborts the whole change set. The engine returns nothing +partial. The caller's base tree never changes: the engine deep-copies it first, and +deep-copies every value it writes. + +## 7. What the engine returns + +This replaces D33 in `spikes/engine-spike.md` and settles the contradiction the review +found. The engine has a warning channel. The engine does not own the response. + +```python +@dataclass(frozen=True) +class ChangeSetResult: + data: dict # the new tree + changed: bool # False when data equals the base, field for field + warnings: list[Warning] +``` + +```python +apply_change_set(base, delta, scope_policy=None, *, validate=None) -> ChangeSetResult +``` + +`changed` is the engine's own comparison of its input base against its output. It is NOT +the commit's no-change answer. The commit wrapper compares the canonical persisted form, +which is a different and larger comparison. See `commit-transaction.md` section 5. + +A `Warning` is structured, never a sentence alone: + +```json +{ + "code": "wholesale_list_replace", + "message": "The delta replaced the whole 'tools' list. Use add_item / remove_item.", + "target": ["parameters", "agent", "tools"], + "operation_index": 0 +} +``` + +### 7.1 Warning codes + +| Code | When | +|---|---| +| `wholesale_list_replace` | A `set` or a legacy `set` replaced a whole `tools`, `skills`, or `mcps` list. | +| `legacy_duplicate_key` | A collection the change set did not touch holds a duplicate key. | +| `legacy_delta_form` | The delta used the legacy form. | +| `unaddressable_embed` | A touched collection holds an `@ag.embed` entry that no operation can name. | + +## 8. Unique names + +This answers the review's "existing duplicate names" call. The rule protects new +configurations without making old ones uncommittable. + +Definitions: + +- A collection is **item-touched** when an `add_item`, `replace_item`, or `remove_item` + operation names it. +- A collection is **branch-touched** when a `set`, `merge`, `remove`, or a legacy `set` + writes it or any of its ancestors. A full-data commit branch-touches every collection. + +Rules, checked after every operation, in final validation: + +1. An item-touched collection must hold no duplicate key. A duplicate is + `duplicate_item_key`. The agent must repair what it edits. +2. A branch-touched collection must not gain a duplicate. The engine compares the base + and the result. A key whose duplicate count rises is `duplicate_item_key`. A duplicate + that already existed and did not grow gives the `legacy_duplicate_key` warning. +3. An untouched collection gives the `legacy_duplicate_key` warning and nothing more. + +The engine already refuses to act when it addresses a duplicated key inside an operation. +That check stays. It gives `duplicate_item_key` with `match_count`. + +Rule 2 keeps every existing configuration committable. A separate cleanup migration can +repair old duplicates later. + +## 9. The scope policy + +```python +ScopePolicy = Callable[[Target], Optional[str]] # a refusal message, or None +``` + +The engine checks every operation's target before it applies any operation. A refusal is +a policy answer. It must not depend on how far the change set got. The error names the +operation index, and the tree stays untouched. + +For the legacy form, the engine builds targets: it walks the `set` tree down to the +policy's prefix depth, and it splits each `remove` path on the dot. + +Two policies exist. `read-config.md` section 11 defines both. + +- `PARAMETERS_ONLY` for a run override: the target must sit under `parameters`. +- `AGENT_COMMIT_SCOPE` for a platform-tool commit: it also refuses server-owned fields. + +A refusal is `out_of_scope`, HTTP 422, not retryable. + +## 10. The error model + +One failure aborts everything. HTTP 422 for a bad change set. HTTP 409 for a stale base; +see `commit-transaction.md`. + +```json +{ + "detail": { + "code": "change_set_rejected", + "message": "No revision was committed.", + "operation_index": 1, + "operation": "edit_text", + "target": ["parameters", "agent", {"field": "skills", "key": "release-qa"}, "body"], + "reason": { + "code": "text_not_unique", + "message": "old_text matched 3 times. Include more surrounding text.", + "match_count": 3 + }, + "retryable": true + } +} +``` + +| Reason code | Meaning | Retryable | +|---|---|---| +| `target_not_found` | A segment does not exist. | yes | +| `target_type_mismatch` | A node has the wrong type for the verb. | yes | +| `item_already_exists` | `add_item` found the key. | yes | +| `item_not_found` | `replace_item` / `remove_item` did not find the key. | yes | +| `duplicate_item_key` | Two entries share one key. | yes | +| `text_not_found` | The anchor does not occur. | yes | +| `text_not_unique` | The anchor occurs more than one time. | yes | +| `text_edits_overlap` | Two matches share a character. | yes | +| `text_too_large` | The target string is above the work limit. | no | +| `no_change` | The edits produce identical content. | yes | +| `empty_old_text` | The anchor is empty. | yes | +| `unkeyed_collection` | The list has no key field. | yes | +| `item_key_undefined` | The value has no derivable key. | yes | +| `source_not_found` | The runner could not read the workspace path. | yes | +| `source_invalid` | The source is unusable, or `value_from` reached the engine. | no | +| `source_too_large` | The source is above the byte limit. | no | +| `out_of_scope` | The scope policy refuses the target. | no | +| `invalid_delta` | Both forms, no form, or an unknown delta field. | no | +| `invalid_operation` | A shape error. | no | +| `final_validation_failed` | The finished tree is not a valid configuration. | yes | + +`final_validation_failed` carries an `issues` array, so the agent gets every schema +problem at once. + +## 11. Final validation + +The engine takes a `validate` callable. The callable receives the finished tree. It +returns a list of issues, or it raises. Either way the engine raises one error with +`final_validation_failed`. + +The commit wrapper supplies the validator. It validates the complete revision data +against the workflow schema, and the agent template against `AgentTemplateSchema`. It also +runs the unique-name rules of section 8. + +## 12. Changes the prototype needs + +The prototype is `api/oss/src/core/workflows/change_set.py` in worktree +`agent-a2a2adaa5d154d454`. It implements this contract except for the following points. + +| # | Change | Where | +|---|---|---| +| 1 | Return `ChangeSetResult`, not a bare dict. Compute `changed`. Collect warnings. | `apply_change_set`, `_finish` | +| 2 | Split `VALUE_BEARING`. `merge` accepts `value` only; the schema must not offer it `value_from`. | `VALUE_BEARING`, `_operation_value` | +| 3 | Accept and dispatch `match_mode`. Add a matcher table with one entry, `exact`. | `_apply_operation`, `apply_text_edits` | +| 4 | Count occurrences with overlap. Replace `str.count` and `str.index`. | `apply_text_edits` | +| 5 | Create missing plain-string object parents in `set`, under the five rules of 5.3. | `_apply_operation` | +| 6 | Add the work limits of 5.6.2 and the `text_too_large` code. | `apply_text_edits` | +| 7 | Add the unique-name rules of section 8 and the warning codes of 7.1. | new module functions | +| 8 | Add `AGENT_COMMIT_SCOPE`. | scope policies | +| 9 | Add the `maxItems` limits to the schema, and the pydantic operation models with `extra="forbid"`. | new module | + +Everything else in the prototype matches this contract. Its 120 tests stay valid, except +the two that pin non-overlapping counting and the absence of parent creation. + +## 13. Open items + +1. **`match_mode` on the wire today.** The catalog schema will advertise a one-value enum. + A model may read that as noise. We accept the cost, because adding a second mode later + is then not a breaking change. +2. **Rule 1 of section 8.** It asks an agent to repair a duplicate it did not create, + before it can edit that collection. This is a product call. `decisions.md` open call 2 + covers it. +3. **Full-data commits.** They branch-touch everything, so rule 2 applies to them. The + playground saves this way. We must measure how many existing configurations would gain + a warning before we make rule 2 stricter. diff --git a/docs/design/agent-config-editing/contracts/commit-transaction.md b/docs/design/agent-config-editing/contracts/commit-transaction.md new file mode 100644 index 0000000000..59c576c1d1 --- /dev/null +++ b/docs/design/agent-config-editing/contracts/commit-transaction.md @@ -0,0 +1,313 @@ +# Contract: the atomic commit transaction and its response + +Status: proposed. It answers must-fix item 2 of the design gate review. +Owner: engine-spike. Date: 4 August 2026. + +This document defines one transaction. It also defines the wire response for a commit +that changes nothing, and for a commit built on a stale base. + +## 1. What the code does today + +Three separate transactions run for one delta commit. + +| Step | Code | Session | +|---|---|---| +| Read the head | `_resolve_revision_delta` calls `fetch_workflow_revision` | its own | +| Enrich the data | `commit_workflow_revision` | none | +| Insert | `workflows_dao.commit_revision` opens `self.engine.session()` | its own | + +Sources: `api/oss/src/core/workflows/service.py:1852` (commit), +`api/oss/src/core/workflows/service.py:1984` (`_resolve_revision_delta`), +`api/oss/src/dbs/postgres/git/dao.py:1565` (`commit_revision`). + +Four facts matter for the design. + +1. **A row lock already exists, for one case only.** `commit_revision` locks the variant + row with `SELECT ... FOR UPDATE` when `initial=True` + (`api/oss/src/dbs/postgres/git/dao.py:1606`). The mechanism is there. We extend it. +2. **The DAO swallows exceptions.** `commit_revision` carries + `@suppress_exceptions(exclude=[InitialRevisionConflict])`. Any other exception becomes + a log line and a `None` return (`api/oss/src/utils/exceptions.py:85`). A conflict error + raised inside the DAO would disappear. +3. **The service enriches the data after the read and before the insert.** It normalizes + snippet data, infers `url` from `uri`, merges the interface `schemas`, infers the + `outputs` schema, and infers `flags`. All five helpers are synchronous and do no I/O + (`sdks/python/agenta/sdk/engines/running/utils.py:518,586,663,702,966`). +4. **The router invalidates the cache and emits a second event, always.** It calls + `invalidate_cache` and `_emit_committed_revision_data_event` after every commit + (`api/oss/src/apis/fastapi/workflows/router.py:1557`). The service emits + `publish_revision_event` separately. + +## 2. The invariant + +> Between the head read that the change set applies to, and the insert of the new +> revision, no other revision for that variant may be inserted. + +A comparison that lives only in `_resolve_revision_delta` does not give this. Two callers +can both read head N, both pass the base check, and both insert. The head must be read +under a lock that the insert holds until it commits. + +## 3. The transaction + +One database session. One `SELECT ... FOR UPDATE` on the variant row. Everything else +happens inside. + +```text +BEGIN + 1. SELECT * FROM variants WHERE project_id = ? AND id = ? FOR UPDATE + -> variant missing: ROLLBACK, 404 + 2. SELECT the latest non-archived revision for that variant -> head + 3. IF base_revision_id is present AND base_revision_id != head.id + -> ROLLBACK, 409 revision_conflict (section 6) + 4. candidate = build(head.data) (section 4, pure, no I/O) + -> ChangeSetError: ROLLBACK, 422 (change-set.md section 10) + 5. validate(candidate) (schema + unique names) + -> issues: ROLLBACK, 422 final_validation_failed + 6. canonical_new = canonicalize(candidate) (section 5) + canonical_head = canonicalize(head.data) + 7. IF canonical_new == canonical_head + -> COMMIT (nothing was written), status = no_change, return head + 8. INSERT the new revision row + 9. compute and store the version number +COMMIT + -> status = committed, return the new revision +``` + +Step 7 commits an empty transaction only to release the lock. It writes no row. + +### 3.1 The seam in code + +`commit_revision` grows a checked sibling. The service keeps its enrichment logic, but +gives it to the DAO as a callback, because the callback must run while the lock is held. + +```python +async def commit_revision_checked( + self, *, project_id, user_id, variant_id, + base_revision_id: Optional[UUID], + build: Callable[[Optional[Revision]], BuildOutcome], +) -> CommitOutcome: + ... +``` + +Three rules on `build`: + +1. It is synchronous. It must not `await`. An await inside an open transaction that holds + a row lock is a deadlock risk and a lock-hold-time risk. +2. It does no I/O. It calls the engine and the five enrichment helpers. All are pure. +3. It raises `ChangeSetError` or `ValidationError`. It never returns a partial tree. + +`commit_revision_checked` must add `RevisionConflict` and `ChangeSetError` to the +`suppress_exceptions(exclude=[...])` list. Fact 2 of section 1 explains why. Without it, +a 409 becomes a silent `None`, and the router answers `count: 0`. + +### 3.2 Lock scope and cost + +The lock is one row, in one project, for one variant. It is held for the length of a pure +in-memory transformation over a configuration tree of at most a few hundred kilobytes. +Two commits to the same variant queue. Two commits to different variants do not meet. + +A statement timeout must bound the wait. A caller that waits longer than the timeout gets +503, not a hung request. + +## 4. `build`: what it does, in order + +```text +build(head): + base = head.data as a plain dict, or {} when the variant has no data revision + result = apply_change_set(base, delta, scope_policy, validate=None) + data = WorkflowRevisionData(**result.data) + data = normalize_snippet_data(data) + data = infer url from uri (when uri is set and url is not) + data = merge interface schemas (retrieve_interface + infer_outputs_schema) + flags = infer_flags_from_data(...) + return BuildOutcome(data=data, flags=flags, warnings=result.warnings) +``` + +A full-data commit skips the engine and starts from the supplied `data`. Every later step +is identical, so both paths produce the same canonical form. + +## 5. Canonicalization and the equality test + +The comparison happens on the form that would be stored, not on the engine's output. The +enrichment of section 4 fills `url`, `schemas`, and `flags`. The stored head already went +through the same pipeline. A comparison before enrichment reports a change when only the +enrichment differs. + +```text +canonicalize(data) = data.model_dump(mode="json", exclude_none=True) + with every object key sorted, recursively +``` + +Three rules: + +1. **Validate before you compare.** An invalid change set must fail with 422, even when + its result would equal the head. A caller who sends a bad operation must learn that. +2. **Compare the canonical persisted data only.** `message`, `name`, `description`, + `tags`, and `meta` are not part of the comparison. A commit that changes only the + message is a no-change commit. It creates no revision. +3. **List order is data.** Two `tools` lists with the same entries in a different order + are not equal. `add_item` appends, so a remove-then-add of the same entry moves it to + the end and is a real change. + +## 6. Precedence + +The order is fixed. A stale base always wins. + +1. The variant does not exist: 404. +2. `base_revision_id` is present and does not equal the head: **409**. +3. The change set fails: 422. +4. Final validation fails: 422. +5. The canonical result equals the head: **200 `no_change`**. +6. Otherwise: **200 `committed`**. + +Rule 2 beats rule 5 on purpose. A stale caller can produce a result that happens to equal +the new head. Answering `no_change` would tell that caller its base was current. It was +not. The caller must re-read and decide again. + +### 6.1 The 409 body + +```json +{ + "detail": { + "code": "revision_conflict", + "message": "The workflow head changed. No revision was committed.", + "base_revision_id": "019c-old", + "current_revision_id": "019c-new", + "current_revision_version": "17", + "retryable": true + } +} +``` + +The body carries the current head id, so the agent can read that exact revision in one +step. It does not carry the configuration. That would recreate the large-payload problem +the whole project exists to remove. + +## 7. The response + +The review is right that a warning plus a head id does not fit the existing shape. +`WorkflowRevisionResponse` returns a complete revision, and the playground refresh path +depends on it (`api/oss/src/apis/fastapi/workflows/models.py:387`, +`sdks/python/agenta/sdk/agents/adapters/vercel/stream.py:838`). + +So the response always carries a complete revision. Two fields are added. + +```python +class WorkflowRevisionResponse(BaseModel): + count: int + workflow_revision: Optional[WorkflowRevision] + resolution_info: Optional[ResolutionInfo] + retrieval_info: Optional[RetrievalInfo] + # new + status: Optional[Literal["committed", "no_change"]] = None + warnings: Optional[List[CommitWarning]] = None +``` + +- On `committed`, `workflow_revision` is the new revision. `count` is 1. +- On `no_change`, `workflow_revision` is the **current head**, complete and unchanged. + `count` is 1. Every existing consumer keeps working: it gets a real revision, and a + refresh with it is correct. +- `status` is absent on the paths that do not use the checked commit. A reader must treat + an absent `status` as `committed`. + +```python +class CommitWarning(BaseModel): + code: str + message: str + target: Optional[List[Union[str, TargetSelector]]] = None + operation_index: Optional[int] = None +``` + +The warning codes are in `change-set.md` section 7.1, plus one the wrapper owns: + +| Code | When | +|---|---| +| `no_change` | The commit produced no new revision. | + +The engine returns warnings. The wrapper puts them on the response. This is the answer to +the D33 / O8 contradiction the review found. + +## 8. `base_revision_id` + +| Caller | Rule | +|---|---| +| Ordered delta | Required. A missing value is 422 `invalid_delta`. | +| Legacy delta from the runner | The runner fills it from `$ctx.workflow.revision.id` when the model omits it. This is defaulting, not binding: a model-supplied value wins. | +| Legacy delta, direct API call | Optional. When absent, no base check runs, and today's last-write-wins behavior stays. A warning says so. | +| Full-data commit | Optional, same rule. | + +The default must NOT go through `context_bindings`. That mechanism overwrites a +model-supplied value (`sdks/python/agenta/sdk/agents/platform/op_catalog.py:91`). An agent +that hit a 409 would stay pinned to its stale run revision and could never retry inside +the same run. The runner fills the field only when it is absent. + +On a draft run there is no `$ctx.workflow.revision.id`. See `read-config.md` section 10. + +## 9. Events and cache + +| Path | `publish_revision_event` | `_emit_committed_revision_data_event` | `invalidate_cache` | +|---|---|---|---| +| `committed` | yes | yes | yes | +| `no_change` | **no** | **no** | **no** | +| 409 / 422 | no | no | no | + +The router calls `invalidate_cache` and `_emit_committed_revision_data_event` +unconditionally today (`api/oss/src/apis/fastapi/workflows/router.py:1557`). Both calls +must become conditional on `status == "committed"`. + +This is not only tidiness. A commit event evicts the warm session. A no-change commit that +emitted the event would throw away a warm sandbox for nothing, which is exactly the cost +RFC Q5 wants to remove. + +## 10. Errors across the router decorators + +The commit endpoint carries `@intercept_exceptions()` and +`@suppress_exceptions(default=..., exclude=[HTTPException])`. A `ChangeSetError` or a +`RevisionConflict` that reaches that decorator becomes a default response, not a 4xx. + +The service layer must therefore translate: + +| Internal | HTTP | +|---|---| +| `ChangeSetError` | `HTTPException(422, detail=error.to_detail())` | +| `RevisionConflict` | `HTTPException(409, detail={...})` | + +The translation lives at the service or router boundary, and `HTTPException` is already +excluded from suppression. + +## 11. Tests this contract requires + +1. **Two writers, one winner.** Two concurrent commits on one variant, both built on head + N. Exactly one gets 201-equivalent `committed`. The other gets 409 with + `current_revision_id` equal to the winner's id. Run it against a real database, with + two sessions. +2. **No-change against a moving head.** A stale commit whose result equals the NEW head + must get 409, not `no_change`. +3. **No-change is clean.** A commit whose result equals its own base head returns + `no_change`, inserts no row, publishes no event, and invalidates no cache. Assert the + revision count before and after. +4. **Validation beats no-change.** A change set with an invalid operation whose result + would equal the head returns 422. +5. **Canonical equality.** A commit that only reorders object keys is `no_change`. A + commit that only reorders a list is `committed`. +6. **Message-only commit.** A new `message` with an identical tree is `no_change`. +7. **Enrichment parity.** A full-data commit and an equivalent delta commit produce the + same canonical stored data. +8. **Suppression.** A forced `RevisionConflict` inside the DAO reaches the client as 409, + not as `count: 0`. +9. **Lock timeout.** A commit that waits longer than the statement timeout fails loudly. + +## 12. Open items + +1. **Where does `build` live?** It must run inside the DAO's session, but it is service + logic. The callback keeps the layering. An alternative is to move the head read into + the service and pass an open session down. The callback is smaller and is preferred. +2. **Archived revisions.** The head read uses `include_archived=False`, like + `fetch_workflow_revision` today. Confirm that archiving the head cannot make an older + revision the base for a commit that a caller built on the archived one. +3. **The v0 seed.** A variant with a null-data seed revision has `data = None`. `build` + starts from `{}`. Confirm the resulting first real revision numbers correctly, because + `commit_revision` nulls the fields of version `0` + (`api/oss/src/dbs/postgres/git/dao.py:1668`). +4. **Statement timeout value.** Pick it with the team, and make it an env setting through + `api/oss/src/utils/env.py`. diff --git a/docs/design/agent-config-editing/contracts/execution-authorization.md b/docs/design/agent-config-editing/contracts/execution-authorization.md new file mode 100644 index 0000000000..11d50d4252 --- /dev/null +++ b/docs/design/agent-config-editing/contracts/execution-authorization.md @@ -0,0 +1,396 @@ +# Contract: execution authorization for `value_from` + +Status: proposed. This contract answers must-fix item 4 and answer section 2 of +`research/design-gate-review-codex.md`. + +This contract replaces the tool-call-id cache in `spikes/runner-spike.md`. The cache was not +safe. This document defines what replaces it. + +## 1. Why the cache was not safe + +The runner resolves `value_from` into a skill value. A human then approves that value. The +runner then executes the call. Three things can go wrong between the approval and the +execution. + +The relay directory is writable from inside the sandbox. Any process in the sandbox can write +an execute record. The record does not prove that a dialog ran. The runner-side guard catches +most of this, but it does not catch all of it. Read +`services/runner/src/engines/sandbox_agent/relay-guard.ts` lines 14 to 22. The guard passes +every `ask` verdict on a non-Pi harness. It passes because the harness raises its own dialog, +and the runner records no grant for that dialog. The module comment states the residual risk in +plain words: a forged request file can start an `ask` tool with no dialog. + +A cache keyed only on the tool-call id inherits that hole. An attacker in the sandbox writes a +record with the approved tool-call id and different arguments. The cache hits. The runner +executes. + +A cache miss is also unsafe. The spike made a cache miss fall back to inline resolution. An +attacker then removes the need for a cache entry. The attacker forges a record for a tool-call +id the runner never gated. The cache misses. The runner reads the folder and commits it. + +So the runner needs a record that binds the approval to one exact call. The runner must consume +that record exactly once. A missing record must stop the call. + +## 2. The execution authorization + +An execution authorization is one record. The runner creates it when it resolves a +`value_from`. The runner consumes it when it executes the call. The record lives in runner +memory only. + +### 2.1 Fields + +| Field | Type | Purpose | +|---|---|---| +| `authorizationId` | string | A random identifier. The runner uses it in logs. It is not a capability. | +| `toolName` | string | The canonical tool name. It must equal the executed spec's name. | +| `toolCallId` | string | The harness's identifier for the call. It correlates the record. It does not authorize it. | +| `argsDigest` | string | SHA-256 over the canonical form of the model's ORIGINAL arguments. The arguments still hold `value_from`, not the resolved value. | +| `frozenValueRef` | handle | An opaque handle into the frozen-value store. Section 5 defines the store. The record never holds the bytes. | +| `contentDigest` | string | SHA-256 over the canonical form of the FULL resolved value, including every file's bytes. | +| `manifestDigest` | string | SHA-256 over the approval manifest. Section 4 of `workspace-import.md` defines the manifest. | +| `catalogGeneration` | string | The tool-catalog generation that was live when the runner minted the record. | +| `sourcePath` | string | The import path the model asked for. It is used for the card and for logs. | +| `operationIndex` | integer | The index of the operation inside `delta.operations`. One record covers one operation. | +| `createdAtMs` | integer | Mint time. | +| `expiresAtMs` | integer | Hard deadline. Section 6 defines it. | +| `consumed` | boolean | Single-use flag. Section 3.3 defines the transition. | +| `turnId` | string | The turn that minted the record. | +| `sessionId` | string | The session that minted the record. | + +### 2.2 What each binding stops + +| Binding | Attack it stops | +|---|---| +| `toolName` | A forged record that names a different tool but reuses the tool-call id. | +| `argsDigest` | A forged record that keeps the tool-call id and changes the arguments. This is the same-id argument substitution case. | +| `contentDigest` | A folder that changes on disk between approval and execution. The runner executes the frozen bytes and proves they are the approved bytes. | +| `catalogGeneration` | A record minted under an old tool catalog. The tool's meaning may have changed. | +| `expiresAtMs` | A record replayed long after the approval. | +| `consumed` | A record replayed inside the window. | + +### 2.3 Canonical forms + +`argsDigest` and `contentDigest` both need one canonical serialization. The runner already has +one. `canonicalJson` in `services/runner/src/responder.ts` sorts object keys and rejects any +value that is not plain JSON. The authorization store must reuse it. + +The runner must fail closed when canonicalization fails. It must not fall back to a weaker key. +`ApprovedExecutionGrants.grant` in the same file already fails closed on an unkeyable call. The +authorization store must do the same, but it must also refuse to mint the record. A grant that +cannot be keyed is a silent no-op today. An authorization that cannot be keyed must be an error +the model sees. + +## 3. Lifecycle + +### 3.1 Mint + +The runner mints a record when it resolves a `value_from`. This happens at the permission gate, +before the approval card is built. + +The steps run in this order. + +1. Read the permission plan verdict for the call. +2. Resolve the `value_from` under the import contract. See `workspace-import.md`. +3. Write the frozen value into the frozen-value store. Get a handle back. +4. Compute `argsDigest`, `contentDigest`, and `manifestDigest`. +5. Read the live `catalogGeneration`. +6. Store the record, keyed on `toolCallId` plus `operationIndex`. +7. Build the approval card from the manifest. + +A resolution failure stops the call before step 3. The model receives the structured error from +the import contract. The runner mints no record. + +### 3.2 Verify + +The runner verifies before every execution. The check runs inside the relay execution guard, or +immediately after it. It must run for every harness. It must not depend on the harness raising a +dialog. + +The check is: + +1. Look up the record by `toolCallId` and `operationIndex`. +2. A missing record fails closed. Section 4 states the one exception. +3. A `consumed` record fails closed. +4. An expired record fails closed. +5. `toolName` must equal the executed spec's name. Otherwise fail closed. +6. Recompute the digest of the incoming record's arguments. It must equal `argsDigest`. + Otherwise fail closed. +7. `catalogGeneration` must equal the live generation. Otherwise fail closed. +8. Read the frozen value through `frozenValueRef`. Recompute its digest. It must equal + `contentDigest`. Otherwise fail closed. + +Every failure returns a deny reason as the tool result text. The model loop continues. This is +the same shape a dialog deny uses today. + +### 3.3 Consume + +The runner marks the record `consumed` before it starts the call. It does not mark it after. +The order matters. A crash between the call and the mark would leave a reusable record. + +The mark and the read must be one atomic step in the runner's event loop. A `Map.delete` that +returns the entry gives this for free in JavaScript. Use that shape rather than a read followed +by a write. + +The runner then substitutes the frozen value into the call body. It replaces `value_from` with +`value`. It never rereads the folder. + +### 3.4 Discard + +The runner discards a record on every one of these events. + +- The turn ends, for any reason. +- The human denies the gate. +- The record expires. +- The session is evicted from the pool. +- The turn is aborted or the client disconnects. +- The environment is destroyed. + +Discard also releases the frozen bytes. Section 5 defines the release. + +## 4. Fail closed, and the one exception + +A missing record must stop a gated call. The runner must never reread the folder to recover. + +The runner may resolve inline only when the permission plan classifies the call as allowed +without a gate. Concretely: `decide()` in `services/runner/src/permission-plan.ts` returns a +`Verdict` of `{kind: "allow"}` for that exact gate descriptor. A `pendingApproval` verdict, an +`ask` verdict, or any verdict the runner cannot compute must fail closed. + +This is a narrow and explicit test. It is not a cache miss. The difference matters. A cache miss +is the absence of information. An `allow` verdict is a positive statement by the policy owner. + +Three further rules apply to the inline path. + +1. The runner must compute the verdict from the permission plan, not from the relay guard's + pass-through. The relay guard passes `ask` on non-Pi harnesses. That pass is a compatibility + behavior, not a policy statement. Reading it as one would reopen the hole. +2. The inline path must still mint and consume a record. It mints, verifies, and consumes in one + step. This keeps one execution path and one set of digests. +3. The inline path must apply the same limits as the gated path. See section 6. + +Open product call. Item 4 in `decisions.md` recommends that the runner follows the run's policy +and forces no gate. The gate review recommends the opposite for v1: force a gate, because tool +permission and workspace-read permission are different policies. This contract implements the +narrower behavior the coordinator specified, which allows an ungated path behind an explicit +`allow`. If Mahmoud accepts the reviewer's call, delete section 4's exception and make every +`value_from` operation force a gate. Nothing else in this contract changes. + +## 5. Where the frozen bytes live + +The frozen value must never travel as ordinary tool arguments. + +The reason is concrete. `InteractionRequest.args` in +`services/runner/src/sessions/interactions.ts` is persisted to the API as a durable interaction +row. A skill folder can hold many kilobytes of text. Putting the resolved value into `args` +would write that content into an interaction row on every gated commit. It would also duplicate +content that the commit itself is about to persist. The gate review names this as the +large-payload problem. + +So the runner keeps two separate things. + +- The **model's arguments** stay exactly as the model wrote them. They still hold + `value_from: {type, path}`. These arguments go into the approval card's argument view, into + the stored decision key, and into the durable interaction row. They are small. +- The **frozen value** lives in a per-turn frozen-value store in runner memory. Only the + authorization record points at it. It is never serialized to the API, never written to the + relay directory, and never sent to the sandbox. + +The store has these rules. + +- It is keyed by an opaque handle. The handle is meaningless outside the runner process. +- It holds one entry per authorization record. +- It enforces the aggregate byte budget in section 6. +- It releases an entry when its record is discarded. +- It releases every entry when the turn ends, even if a record leaked. + +`ParkedApproval` may hold the handle across a park. It must not hold the bytes inline, for the +same reason: the parked approval is what the runner reports and logs. + +## 6. Timeouts, limits, and cleanup + +### 6.1 Abort and deadline + +Resolution takes an `AbortSignal`. The signal combines the turn's own signal with a hard +resolution deadline. The runner already uses this shape in `callDirect` in +`services/runner/src/tools/direct.ts`. + +- Per-source resolution deadline: 30 seconds. A Daytona manifest exec plus reads must finish + inside it. +- The turn's abort signal cancels resolution at once. +- A cancelled resolution mints no record and releases any partial bytes. + +### 6.2 Limits + +These limits apply per turn, not per call. + +| Limit | Value | Reason | +|---|---|---| +| Sources resolved per call | 8 | Bounds one commit. | +| Sources resolved per turn | 32 | Bounds a loop of commits. | +| Authorization records live per turn | 32 | One per source. | +| Aggregate frozen bytes per turn | 8 MiB | Bounds runner memory. | +| Bytes per source | 2 MiB | Defined in `workspace-import.md`. | + +Reaching a limit fails the operation with a structured error. It does not silently truncate. + +### 6.3 Record expiry + +`expiresAtMs` is the earlier of two values. + +- Mint time plus the approval park TTL (`config.approvalTtlMs`). +- Mint time plus 30 minutes. + +The second bound exists because the park TTL is configurable and may be set very long. A frozen +snapshot of a folder should not authorize an execution hours later. + +### 6.4 Cleanup obligations + +The runner must release frozen bytes on every path listed in section 3.4. The turn's `finally` +block is the backstop. It must clear the whole store. A leaked entry is a memory leak on a +long-lived parked session, so the backstop must not be optional. + +## 7. Park and cold resume + +This is the hardest case. The runner parks a session on an approval gate. The human answers +later. Two things can happen. + +### 7.1 Live resume + +The pool still holds the environment. `pool.checkoutApproval` succeeds. The runner answers the +gate on the same live session. + +The authorization records survive, because the environment survives. The runner carries them on +the parked state beside `ParkedApproval`. The records keep their original `expiresAtMs`. The +runner re-verifies every field at consume time, exactly as section 3.2 says. An expired record +fails closed even on a live resume. + +The frozen bytes survive with the records. They count against the turn's byte budget for as long +as the session is parked. This is real memory held across a park. It is bounded by the 8 MiB +aggregate and by the 30-minute expiry. + +### 7.2 Cold resume + +The runner falls back cold in several cases. Read `services/runner/src/server.ts` around line +856. An approval mismatch, an empty decision set, a resume that throws, or a resume that fails +all lead to `coldAndPark()`. The old environment is destroyed. + +Destroying the environment destroys the frozen-value store. The records are gone. + +The rule is simple and it must be enforced. **A cold resume must not execute a `value_from` +operation on the strength of the old approval.** The approval named specific bytes. Those bytes +no longer exist in runner memory. The folder on disk may have changed. Re-resolving it and +executing would run content no human ever saw. + +So the cold path must do this. + +1. The cold turn starts with an empty authorization store. +2. The model replays the conversation and re-issues the tool call, or the approval envelope + arrives with no matching gate. +3. The runner resolves the `value_from` again, mints a NEW record, and raises a NEW gate. +4. The human sees a new approval card, built from the newly read bytes. + +The runner must not treat the incoming `{approved: true}` envelope as an answer to the new gate. +The envelope answers a gate that no longer exists. The runner must surface this clearly, so the +user understands why they are asked twice. + +This is honest but it is not free. A user who approves a large skill, then hits a credential +rotation, is asked to approve again. That is the correct trade. The alternative is executing +unapproved bytes. + +Open question for the reviewer: should the runner persist the frozen value and its digests +durably, so a cold resume can restore the exact snapshot instead of asking again? This would +remove the second prompt. It would also put skill content into durable storage, which section 5 +argues against. This contract chooses to ask again. Record the decision in `decisions.md`. + +## 8. Tool-catalog generation + +`catalogGeneration` is a new value. It does not exist today. + +It is one opaque string per environment. The runner computes it when it builds the tool catalog. +It changes whenever the model-visible catalog changes. `adapter-matrix.md` defines how it is +computed and when it advances. + +The authorization record captures it at mint time. The verify step compares it to the live +value. A mismatch fails closed. + +The reason is direct. A tool named `commit_revision` under generation N may have a different +schema, a different permission, or a different execution binding under generation N+1. An +approval minted under N does not describe the call that would run under N+1. + +## 9. Errors the model sees + +Every failure returns a tool result the model can act on. The runner keeps the detail in its own +logs. It gives the model a stable code and a short message. + +| Code | When | Retryable | +|---|---|---| +| `authorization_missing` | No record for a gated call. | No. The model must reissue the call. | +| `authorization_consumed` | The record was already used. | No. | +| `authorization_expired` | The record passed `expiresAtMs`. | Yes, after reissuing the call. | +| `authorization_mismatch` | Tool name, arguments, or content digest differ. | No. | +| `catalog_generation_stale` | The catalog changed after the mint. | Yes, after reissuing the call. | +| `source_limit_exceeded` | A turn or call limit was reached. | No. | +| `resolution_timeout` | Resolution passed its deadline. | Yes. | +| `resolution_cancelled` | The turn aborted. | No. | + +The import contract owns the `source_*` codes for read failures. See `workspace-import.md`. + +## 10. Test obligations + +These tests gate the slice. They are a contract, not a suggestion. + +**Forged records.** +- A forged relay record for an `ask` tool on a non-Pi harness, with a valid tool-call id and + substituted arguments, must be refused. +- A forged record for a tool-call id the runner never gated must be refused. It must not + trigger a folder read. +- A forged record on the Pi path must be refused. + +**Single use.** +- The same record consumed twice must fail on the second attempt. +- Two concurrent execute records for one authorization must produce exactly one execution. + +**Mutation after approval.** +- Change a file in the folder between the mint and the consume. The execution must use the + frozen bytes. The committed value must equal the approved value. +- Replace the folder with a symlink that leaves the workspace, between mint and consume. The + execution must still use the frozen bytes and must not read the new target. + +**Timeout, denial, abort.** +- A resolution that exceeds its deadline mints no record and leaks no bytes. +- A denied gate discards the record and releases the bytes. +- An aborted turn releases every record and every byte. + +**Expiry.** +- A record consumed after `expiresAtMs` fails closed, on both the live-resume and the ordinary + path. + +**Park and resume.** +- A live approval resume consumes the parked record and commits the approved bytes. +- A cold fallback after an approval mismatch raises a NEW gate. It must not execute the old + approval, and it must not commit anything. + +**Payload placement.** +- The durable interaction row for a gated `value_from` call must hold the model's original + arguments. It must not hold the resolved value. +- The relay request file must not hold the resolved value. + +**Catalog generation.** +- A record minted under generation N, consumed after the catalog advances to N+1, fails closed. + +**Limits.** +- Exceeding the per-turn source count or the aggregate byte budget produces a structured error + and releases every partial allocation. + +All of the above must run on both the local relay host and the Daytona relay host. The gate +review is explicit that static inspection does not prove Daytona behavior. + +## 11. Documents to update when this contract is accepted + +- `spikes/runner-spike.md`, section "Where the resolution step should really live". Replace the + tool-call-id cache with this contract. +- `decisions.md`, the runner-spike block. Replace the "frozen per tool-call id, with inline + resolution at execution as the fallback" line. +- `decisions.md`, open product call 4. Record Mahmoud's answer on the forced gate. +- `plan.md`. Split slice 3 into source codec, authorization and freeze integration, and approval + user interface, as must-fix item 7 requires. diff --git a/docs/design/agent-config-editing/contracts/read-config.md b/docs/design/agent-config-editing/contracts/read-config.md new file mode 100644 index 0000000000..11cbbf7fae --- /dev/null +++ b/docs/design/agent-config-editing/contracts/read-config.md @@ -0,0 +1,384 @@ +# Contract: `read_config`, the editable scope, and the call description + +Status: proposed. It answers must-fix item 3 of the design gate review. +Owner: engine-spike. Date: 4 August 2026. + +Three contracts live here, because they share one surface: the builder tools the +playground gives an agent over its own configuration. + +1. `read_config`: how the agent reads its configuration (sections 2 to 10). +2. The editable scope: which fields a commit may never write (section 11). +3. The R12 `description`: the per-call text, and how it differs from the commit message + (section 12). + +## 1. Why the agent needs this + +RFC user story US-5. The agent cannot read its own configuration today. It guesses, and +after a save it can report the wrong model (#5186). US-7 needs the same tool: after a 409 +the agent must read the new head and retry. + +The tool is playground-only, like the other builder tools. A shared agent does not get it. + +## 2. The catalog entry + +`read_config` is a platform op. The catalog owns everything except the payload. + +```python +PlatformOp( + op="read_config", + description=_READ_CONFIG_DESCRIPTION, + method="POST", + path="/api/workflows/revisions/read-config", + input_schema=_READ_CONFIG_INPUT_SCHEMA, + context_bindings={"target.workflow_variant_id": "$ctx.workflow.variant.id"}, + read_only=True, + timeout_ms=15000, +) +``` + +The binding gives the self-target guarantee. The model cannot name another variant, +because the field is stripped from the model-visible schema and filled server-side +(`sdks/python/agenta/sdk/agents/platform/op_catalog.py:91`). + +The op needs a new endpoint. The existing retrieve endpoint returns a whole revision. It +cannot do partial reads, it cannot answer the draft question, and it returns fields the +model must not see. + +## 3. The request + +```json +{ + "type": "object", + "additionalProperties": false, + "required": ["target"], + "properties": { + "target": { + "type": "object", + "additionalProperties": false, + "properties": { + "workflow_variant_id": {"type": "string"}, + "path": {"$ref": "#/$defs/Target"} + } + }, + "max_bytes": {"type": "integer", "minimum": 1024, "maximum": 262144, "default": 65536} + } +} +``` + +`path` uses the change-set target grammar without any change: a string segment is an +object field, a `{"field", "key"}` segment is one named list entry. See `change-set.md` +section 4. One grammar for read and write is the point. What the agent reads, it can then +name in an operation. + +An absent `path` means the whole readable configuration. + +Examples: + +| Ask | `path` | +|---|---| +| the whole configuration | absent | +| the model | `["parameters","agent","llm"]` | +| the tool list | `["parameters","agent","tools"]` | +| one skill | `["parameters","agent",{"field":"skills","key":"release-qa"}]` | +| one skill body | `["parameters","agent",{"field":"skills","key":"release-qa"},"body"]` | +| one bundled file | `["parameters","agent",{"field":"skills","key":"release-qa"},{"field":"files","key":"scripts/check.py"},"content"]` | + +## 4. The response + +```json +{ + "revision": { + "id": "019c...", + "version": "17", + "workflow_variant_id": "019c...", + "created_at": "2026-08-04T18:22:31Z" + }, + "base_revision_id": "019c...", + "is_draft": false, + "path": ["parameters", "agent", "llm"], + "value": {"model": "openai/gpt-5", "extras": {"reasoning_effort": "high"}}, + "bytes": 74, + "warnings": [] +} +``` + +- `revision` says exactly which version answered. This is RFC requirement 3 on the tool. +- `base_revision_id` is the value the agent must copy into its next commit. It equals + `revision.id`. It is a separate field because the agent must not have to guess which id + the commit wants. +- `is_draft` says whether the run targets a committed revision or an unsaved playground + draft. Section 10 explains what it means for the answer. +- `path` echoes the resolved target, so a truncated model context still knows what it got. +- `value` is the raw value at that path. + +## 5. Exact bytes + +`value` carries the stored string, byte for byte. The endpoint does not normalize Unicode. +It does not fold line endings. It does not trim whitespace. It does not strip a BOM. It +does not re-indent JSON inside a string. + +This is not a style choice. `edit_text` matches exactly (`change-set.md` section 5.6). If +the read cleaned a string, every anchor the agent built from that read would fail against +the stored bytes. Read and write must see the same bytes. + +The transport is JSON, so the string travels as a JSON string. JSON string escaping is +lossless for every code point we store. + +## 6. Output limits + +A read answers fully, or it refuses. It never returns a shortened string. + +```text +if len(json_encode(value)) > max_bytes: + refuse with output_too_large +``` + +The refusal carries what the agent needs to narrow the read: + +```json +{ + "detail": { + "code": "read_config_rejected", + "reason": { + "code": "output_too_large", + "message": "The value at that path is 184232 bytes; the limit is 65536. Read a narrower path.", + "bytes": 184232, + "limit": 65536 + }, + "path": ["parameters", "agent"], + "children": ["instructions", "llm", "tools", "mcps", "skills", "harness", "runner", "sandbox"], + "retryable": true + } +} +``` + +`children` lists the field names, or the item keys, one level under the refused path. The +agent then reads a smaller piece without guessing. For a list, `children` holds the item +keys, which are exactly the selector keys it may use. + +**Why no truncation.** A truncated string is a trap. The agent would build an `edit_text` +anchor from text that ends in the middle of a line, or it would believe a phrase occurs +one time when the hidden tail holds it again. A refusal costs one extra call. A truncated +read costs a wrong commit. The same rule holds for the whole-configuration read: a large +agent must be read in parts. + +## 7. Errors + +Target resolution reuses the change-set reason codes, so the agent learns one vocabulary. + +| Reason code | HTTP | When | +|---|---|---| +| `target_not_found` | 422 | A segment does not exist. | +| `target_type_mismatch` | 422 | A segment walks into a scalar. | +| `item_not_found` | 422 | No entry has that key. | +| `duplicate_item_key` | 422 | Two entries share that key. | +| `unkeyed_collection` | 422 | A selector names a list with no key field. | +| `invalid_operation` | 422 | A malformed segment. | +| `out_of_scope` | 422 | The path names a field the agent may not read. | +| `output_too_large` | 422 | Section 6. | +| `revision_not_found` | 404 | The variant has no revision. | + +The envelope matches the commit error envelope, with `code: "read_config_rejected"` and +`path` in place of `operation_index` / `operation` / `target`. + +## 8. What the read may return + +The read scope and the write scope are not the same. The agent may read more than it may +write, because reading `uri` helps it understand itself, and writing `uri` would break it. + +| Field | Read | Write | +|---|---|---| +| `parameters` and everything under it | yes | yes, section 11 | +| `uri` | yes | no | +| `url` | no | no | +| `schemas` | no | no | +| `flags` | yes | no | + +`url` and `schemas` are server-derived and large. They tell the model nothing it can act +on. Reading them wastes context. A path into them is `out_of_scope`. + +## 9. A read is not a lease + +The head can move between the read and the commit. The read takes no lock and creates no +reservation. `base_revision_id` is what makes the pair safe: the commit fails with 409 if +the head moved (`commit-transaction.md` section 6). The tool description must say this in +one line, so the agent learns the loop: + +> read → build the operations → commit with `base_revision_id` → on 409, read again. + +A read that returns a head which is already stale is not an error. The commit catches it. + +## 10. The draft-run caveat + +This is the hole the review named, spelled out. + +A playground draft lives in the browser and in the runner's memory. No server endpoint can +return it. `is_draft` is true exactly when the run carries workflow identity but no +committed revision reference (`sdks/python/agenta/sdk/agents/tracing.py:166`). On such a +run `$ctx.workflow.revision.id` is absent, and `$ctx.workflow.variant.id` is present. + +So on a draft run: + +- `read_config` returns the **committed head**, not the configuration that is running. +- The commit also applies to the committed head. So the read and the write still agree. + The agent's edit lands on a coherent base. +- The agent's own running instructions may differ from what it just read. It must not + assume that the text it reads is the text it is following. + +The response must say this, not only through a flag: + +```json +{ + "is_draft": true, + "warnings": [ + { + "code": "draft_run", + "message": "This run executes unsaved playground changes. The values below come from the committed head, revision 17. Your commit will also apply to the committed head." + } + ] +} +``` + +The runner must fill `base_revision_id` for a draft-run commit from the read, not from +`$ctx.workflow.revision.id`, because that context value is absent +(`commit-transaction.md` section 8). + +Two consequences we accept for v1: + +1. An agent on a draft run can silently overwrite the user's unsaved browser edits, in the + sense that its commit does not include them. The commit is still correct against the + head, and the browser draft is untouched. +2. An `edit_text` anchor an agent copies from its own running instructions can fail on a + draft run, because the head holds different text. The failure is loud + (`text_not_found`), which is the behavior we want. + +## 11. The editable scope for commits + +R7 says server-owned fields stay outside the model's control. The prototype's commit +policy is `allow_all`. That is the gap. + +The model-facing catalog already narrows the envelope: `_COMMIT_REVISION_INPUT_SCHEMA` +exposes `workflow_variant_id`, `message`, and `delta` only +(`sdks/python/agenta/sdk/agents/platform/op_catalog.py`). The model cannot send `data`, +`flags`, `name`, `description`, `tags`, or `meta`. So the hole is inside the delta: a +`set` on `uri`, `schemas`, or `flags` passes today. + +### 11.1 The policy + +`AGENT_COMMIT_SCOPE` is a scope policy in the sense of `change-set.md` section 9. It runs +for every commit that arrives through the `commit_revision` platform tool. It does not run +for a human or an SDK caller on the normal API. + +| Target root | Rule | +|---|---| +| `parameters` | allowed | +| everything else | refused, `out_of_scope` | + +Inside `parameters`, four subtrees are refused: + +| Path | Why | +|---|---| +| `parameters.agent.sandbox.kind` | The sandbox provider is a security and cost boundary. | +| `parameters.agent.sandbox.permissions` | The security boundary the agent runs inside. | +| `parameters.agent.harness.permissions` | The allow / ask / deny rules that gate its own tools. | +| `parameters.agent.runner.permissions` | The runner-enforced execution policy. | + +An agent that could widen its own permission lists could grant itself any tool. An agent +that could switch its sandbox could leave the boundary a human chose. Both are privilege +escalation, and both are silent. + +`parameters.agent.harness.kind` stays writable. Changing the harness costs a rebuild, and +it is a normal authoring choice, not a security boundary. This is a product call; section +13 lists it. + +### 11.2 Where it runs + +The policy is a parameter of `apply_change_set`. The commit wrapper picks it from the +caller: + +| Caller | Policy | +|---|---| +| the `commit_revision` platform tool | `AGENT_COMMIT_SCOPE` | +| a run override (RFC Q6, out of scope for v1) | `PARAMETERS_ONLY` | +| a human or SDK caller on the API | none | + +The refusal is 422 with `out_of_scope`, and it is not retryable +(`change-set.md` section 10). + +### 11.3 A note on defence in depth + +The catalog schema is the first gate. The scope policy is the second. Final validation is +the third: `AgentTemplateSchema` is closed, so an invented field fails even if both gates +missed it. Keep all three. The catalog can be widened by mistake in one line. + +## 12. R12: the per-call description + +R12 asks for an optional agent-written description on every builder tool call, shown in +the frontend with the call and its result. + +### 12.1 A name collision to avoid + +`RevisionCommit` already has a persisted `description` field, beside `name` and `message` +(`api/oss/src/dbs/postgres/git/dao.py:1596`). It is a revision field. It is stored, and it +appears in the history. + +The R12 text is not that. It is a per-call note about what the agent is doing and why. Two +different things must not share one field name on one object. + +### 12.2 The contract + +| Field | Where | Persisted | Purpose | +|---|---|---|---| +| `description` | the tool-call envelope, beside `workflow_revision` | **no** | The agent explains this call to the human watching. | +| `message` | inside `workflow_revision` | yes | The commit message on the revision. | +| `RevisionCommit.description` | server-side only | yes | An existing revision field. The model never sets it. | + +```json +{ + "description": "Adding the pdf-tools skill you asked for, and pointing the instructions at it.", + "workflow_revision": { + "message": "Add the pdf-tools skill.", + "base_revision_id": "019c...", + "delta": { "operations": [ ] } + } +} +``` + +Rules: + +1. `description` is optional on every builder tool: `commit_revision`, `read_config`, + `test_run`, and any later one. It is a catalog-level field, so it is defined once. +2. It is ephemeral. The runner reads it, attaches it to the tool-call record the frontend + renders, and **removes it before it builds the HTTP request**. The API never receives + it. No endpoint schema changes. +3. It is free text, maximum 500 characters. Longer is truncated for display, and the + truncation is visible. +4. It is never a substitute for `message`. `message` describes the change in the history. + `description` describes the call in the conversation. A commit may set one, both, or + neither. +5. On an approval card, `description` is shown as the agent's stated intent. It is model + text. The card must never present it as a fact about what the call does. The card + shows the real diff beside it. + +### 12.3 Why the runner strips it + +Two reasons. First, no API schema has to change, so the field costs nothing on the server. +Second, an ephemeral note must not become part of the audit trail by accident. If we later +want to persist it, we do that on purpose, with a decision. + +## 13. Open items + +1. **`harness.kind` writability** (section 11.1). It is currently writable. Is a + self-directed harness switch acceptable? A wrong choice can make an agent unable to + run, and only a human can undo it. +2. **`parameters` beyond `agent`.** A workflow revision can hold other `parameters` + subtrees, such as `prompt`. Should a builder agent be able to write them? The current + policy allows it. +3. **Storing the authored operations for audit.** The RFC promises to store the diff with + the commit. This contract does not do it. The review lists it as missing. It needs its + own decision, because it adds a column or a meta field. +4. **`max_bytes` default.** 65536 is a guess. Measure a real agent configuration before we + fix it. +5. **Draft reads, later.** Section 10 accepts that a draft run reads the head. RFC Q3 + Option C (the runner answers from memory) stays parked. If users find the caveat + confusing, that option comes back. diff --git a/docs/design/agent-config-editing/contracts/workspace-import.md b/docs/design/agent-config-editing/contracts/workspace-import.md new file mode 100644 index 0000000000..6f81937f52 --- /dev/null +++ b/docs/design/agent-config-editing/contracts/workspace-import.md @@ -0,0 +1,556 @@ +# Contract: the workspace import boundary + +Status: proposed. This contract answers must-fix item 5 of +`research/design-gate-review-codex.md`, and product calls 5, 6, and the eighth call in its +section 5. + +This contract defines how the runner reads a folder from its workspace and turns it into a +skill value. It replaces the behavior in the `skill-codec.ts` prototype. The prototype was +lossy by default and derived policy from filesystem facts. Both are wrong. + +## 1. Principles + +Four rules drive every decision in this document. + +1. **Safe by default.** The import must not read a file the user did not intend to share. +2. **Lossless by default.** The import must not silently drop content. A partial skill must + never be committed without an explicit opt-in. +3. **Policy is explicit.** A filesystem fact never becomes a permission grant. +4. **What is approved is what is committed.** The digest covers the bytes that reach the API. + +## 2. The import root + +### 2.1 The rule + +The runner reads only from a designated import root. The root is `imports/` under the run's +workspace current working directory (`plan.workspace.cwd`). + +A `value_from.path` is relative to that root. The path `downloaded-skills/pdf-tools` resolves to +`/imports/downloaded-skills/pdf-tools`. + +The runner refuses any path that resolves outside the root. It refuses before it reads. + +### 2.2 Why not the whole workspace + +Open product call 6 in `decisions.md` recommends the whole workspace, with the approval manifest +as the control. The gate review rejects that, and this contract follows the review. + +The reason is that a manifest is a poor control against a secret. The workspace holds the +agent's own working files. It holds `AGENTS.md`, harness configuration files, and whatever the +agent wrote during the run. A prompt-injected agent can point `value_from` at any of them. The +human then sees a manifest of file names and sizes. A human approving a skill does not read a +manifest as a security boundary. They see a plausible list and they approve. + +A designated root moves the control earlier. The agent must first place content in `imports/`. +That placement is an ordinary file write, which the run's own permission policy already +governs. The import boundary then only has to enforce one thing: stay inside the root. + +### 2.3 Root behavior + +- The runner creates `imports/` during workspace preparation. It creates it empty. +- The root lives inside the durable workspace, so content placed there survives a warm turn. +- The runner never deletes user content from the root. Cleaning it is the agent's job. +- An import path that names the root itself is refused. The caller must name one folder. + +Note for the plan: creating this directory is a change to `prepareWorkspace` in +`services/runner/src/engines/sandbox_agent/workspace.ts`. It belongs in the same slice as the +codec. + +## 3. Confinement + +### 3.1 Two checks, both required + +The runner performs a lexical check and a real-path check. Neither is sufficient alone. + +The lexical check rejects, before any filesystem access: + +- an absolute path; +- any `..` segment; +- a backslash separator; +- a NUL byte; +- a path longer than 1024 bytes. + +The real-path check resolves every symbolic link and compares the result to the resolved root. +The resolved target must be the root or must live under it. + +Every file inside the folder gets its own real-path check. A symbolic link deep in the tree +escapes just as well as one at the top. + +### 3.2 The TOCTOU window + +The prototype used separate `realpath`, `stat`, and `read` calls. A folder can change between +them. An attacker in the sandbox can replace a checked file with a symbolic link to a secret, +after the check and before the read. + +This contract closes the window as far as the platform allows. It states plainly where the +window remains. + +**Local runs.** The runner opens each file once, with `O_NOFOLLOW`, and derives everything from +that one open file handle. + +1. Open the entry with `O_NOFOLLOW`. A symbolic link then fails the open. +2. `fstat` the handle. Read the type, the size, and the mode from the handle, not from the path. +3. Read the content from the same handle. +4. Close the handle. + +Directory traversal uses `openat`-style relative descent where the runtime allows it. Node's +`fs.opendir` plus per-entry `O_NOFOLLOW` opens gives the practical equivalent. A symbolic link +inside the tree is refused rather than followed. This is a change from the prototype, which +followed confined links. + +The residual window is the directory walk itself. A directory can be swapped between the walk +and the open. The `O_NOFOLLOW` open bounds the damage: the attacker cannot redirect a read to a +target outside the tree through a link, because links do not open at all. + +**Daytona runs.** Section 6 defines the manifest. The window there is wider and section 6.4 +states it. + +### 3.3 Symbolic links are refused, not followed + +The prototype followed a link whose target stayed inside the workspace. This contract refuses +every symbolic link inside an import folder. + +The reason is the TOCTOU window. A followed link needs a check and then a read, and the two +cannot be made atomic across the Daytona daemon interface. Refusing the link removes the class. + +A refused link is an unsupported entry. Section 4.2 defines what happens to it. + +## 4. What the import accepts + +### 4.1 Required shape + +An import folder must hold a `SKILL.md` at its top level. The file must parse as UTF-8. Its YAML +frontmatter must supply a `description`. Its `name` comes from the frontmatter, or from the +folder's own name when the frontmatter omits it. + +Everything after the closing frontmatter delimiter is the skill body. + +Every other regular file becomes one `files[]` entry. + +### 4.2 Unsupported files: reject by default + +This is the change the gate review requires. The prototype dropped a binary or oversized file +and committed the rest. That produces a skill the user did not approve. + +The rule is now: + +**An unsupported file fails the whole import.** The runner returns `source_unsupported_content`. +It lists every offending path. It commits nothing. + +A file is unsupported when any of these holds: + +- The bytes are not valid UTF-8. +- The file is larger than the per-file cap. +- The entry is a symbolic link. +- The entry is not a regular file or a directory. This covers sockets, devices, and FIFOs. +- The relative path is longer than 255 code points. +- The tree is deeper than 8 levels. + +### 4.3 The explicit opt-in for omission + +A caller who accepts a partial skill states so. The operation carries: + +```json +{ + "value_from": { + "type": "workspace", + "path": "downloaded-skills/pdf-tools", + "on_unsupported": "omit" + } +} +``` + +`on_unsupported` accepts `reject` (the default) or `omit`. + +Under `omit`: + +- The import proceeds without the unsupported files. +- Every omitted path appears in the manifest, with its reason and its size. +- The approval card shows the omissions in their own section, before the file list. +- The runner records the omission list in the interaction row's arguments, because the model + wrote `on_unsupported` and the user must see what it cost. + +Under `omit`, the aggregate byte cap still applies to the files that remain. Exceeding the +aggregate cap always rejects, even under `omit`. A folder that is wholesale too big is a +mistake, not a content type. + +This replaces open product call 5 in `decisions.md`. The recommendation there was to drop +binary files with a warning. This contract rejects by default and makes the drop explicit. + +### 4.4 Caps + +| Cap | Value | On breach | +|---|---|---| +| Per file | 200 000 bytes | Unsupported. Follows `on_unsupported`. | +| Files per folder | 200 | Reject. | +| Aggregate per folder | 2 MiB | Reject, always. | +| Tree depth | 8 | Unsupported. Follows `on_unsupported`. | +| Relative path | 255 code points | Unsupported. Follows `on_unsupported`. | +| `SKILL.md` size | 200 000 bytes | Reject, always. Without it there is no skill. | + +The per-file cap matches `SkillFile.content` in +`sdks/python/agenta/sdk/agents/skills/models.py`. Keeping the two equal stops a runner-side +success from becoming a server-side validation failure. + +## 5. Executable policy + +### 5.1 Never derive policy from mode bits + +The prototype set `allow_executable_files` to true when any file carried the owner-execute bit. +That converts a filesystem fact into a policy grant. The gate review calls this out as a +separate missing product call. This contract removes it. + +### 5.2 The rule + +`allow_executable_files` defaults to false. The import never sets it from the filesystem. + +The caller states the policy on the operation: + +```json +{ + "value_from": { + "type": "workspace", + "path": "downloaded-skills/pdf-tools", + "allow_executable_files": true + } +} +``` + +The field defaults to false when absent. + +### 5.3 How the mode bit is treated + +The runner still reads each file's owner-execute bit. It uses it for two things. + +1. It sets `files[].executable` to the observed bit. This preserves the author's intent inside + the skill package. +2. It reports every executable file in the manifest and on the approval card. + +When `allow_executable_files` is false and the folder holds an executable file, the import +**rejects**. It returns `source_executable_not_permitted` and names the files. + +The import does not silently clear the bit. A silent clear would produce a skill whose scripts +do not run, and the user would learn this much later. + +The materializer's own policy still applies at run time. `resolveSkillDirs` in +`services/runner/src/engines/skills.ts` defaults to `deny`. So an executable file needs three +independent yes answers: the caller's `allow_executable_files`, the skill's stored +`allow_executable_files`, and the sandbox execution policy. That is the intended depth. + +## 6. The Daytona reader + +### 6.1 Why a manifest + +On Daytona the workspace lives inside the sandbox. The runner must read it over the daemon +interface. That interface cannot answer two questions the codec needs. + +`FsEntry` and `FsStat` in `node_modules/sandbox-agent/dist/index.d.ts` carry `entryType`, +`name`, `path`, `size`, and `modified`. They carry no permission mask. So `isExecutable` has no +answer. + +`FsEntryType` is `"file" | "directory"` only. A symbolic link is reported as its target's type. +There is no `realpath` equivalent. So the confinement check has no answer. + +Both gaps are closed with one process execution per import, not one per file. One execution per +file would be far too slow for a folder of any size. + +### 6.2 The manifest command + +The runner runs one command. It runs it with an argument vector, never through a shell. + +``` +find -mindepth 0 -maxdepth 8 -printf '%y\0%m\0%s\0%P\0' +``` + +The fields are the entry type, the octal mode, the size in bytes, and the path relative to the +root. + +Three framing rules make this safe. + +1. **NUL separation.** Fields and records are separated by NUL bytes. A file name may hold a + newline, a tab, a quote, or a backslash. It may not hold a NUL. So NUL is the only safe + separator. The prototype's tab-and-newline framing was not safe. +2. **`%y` not `%Y`.** `%y` reports the type of the entry itself. A symbolic link reports `l`. + The runner then refuses it under section 3.3. `%Y` would follow the link and hide it. +3. **`-maxdepth 8`** bounds the walk inside the command, so a deep or cyclic tree cannot make + the command run long. + +The runner runs a second command to resolve the root itself: + +``` +realpath -- +``` + +The result must equal the resolved workspace import root. This catches a symbolic link at the +root. + +### 6.3 Reading the content + +The runner then reads each accepted file with `readFsFile`. It reads only files the manifest +listed as regular, in-cap, and non-link. + +The read count equals the accepted file count. It does not equal the entry count. + +### 6.4 The residual window, stated plainly + +The manifest and the reads are separate daemon calls. A process inside the sandbox can change a +file between the two. The daemon interface offers no atomic open-and-read, so this window cannot +be closed from the runner. + +Three things bound it. + +1. The runner verifies each read against the manifest's recorded size. A size change rejects the + import with `source_changed_during_read`. +2. The runner re-runs the manifest command after the reads finish. Every accepted entry's type, + mode, and size must be unchanged. Any difference rejects the import. +3. The window is inside the sandbox's own trust boundary. An attacker who can write these files + can already write to `imports/`, and the run's permission policy governs that write. + +This is weaker than the local `O_NOFOLLOW` path. The plan must say so. It must not claim the two +paths give the same guarantee. + +### 6.5 Timeouts, cancellation, and memory + +| Control | Value | +|---|---| +| Manifest command timeout | 10 seconds | +| `realpath` command timeout | 5 seconds | +| Whole-import deadline | 30 seconds | +| Abort signal | The turn's signal, combined with the deadline | +| Manifest output cap | 1 MiB. A larger output rejects with `source_too_large`. | +| Concurrent file reads | 4 | +| Peak buffered bytes | The aggregate folder cap, 2 MiB | + +The runner must not buffer the whole manifest and the whole content at once beyond these caps. +It accumulates content into the frozen-value store as it reads, and it checks the aggregate cap +on every append. + +On cancellation the runner stops issuing daemon calls, releases every buffered byte, and mints +no authorization record. It does not wait for in-flight reads to finish before releasing. + +## 7. The digest + +### 7.1 What it covers + +`contentDigest` is SHA-256 over the canonical serialization of the **complete resolved value**. +It covers `name`, `description`, `body`, every `files[]` entry's `path`, `content`, and +`executable`, plus `disable_model_invocation` and `allow_executable_files`. + +These are the bytes the runner sends to the API. The digest therefore proves that what was +approved is what was committed. + +The digest does **not** cover the source folder's bytes on disk, the file modification times, or +the manifest. Those are inputs, not the committed value. + +### 7.2 Determinism + +The value must serialize the same way every time, or the digest is useless. + +- `files[]` is sorted by `path`, using byte-wise comparison of the UTF-8 encoding. +- Object keys are sorted by the canonical serializer. +- The serializer is `canonicalJson` in `services/runner/src/responder.ts`, the same function the + execution authorization uses. + +### 7.3 The manifest digest + +`manifestDigest` is SHA-256 over the approval manifest, defined in section 8. It exists so the +approval user interface can prove which manifest the human saw. It is separate from +`contentDigest` because the manifest is a truncated view and the content is not. + +## 8. The approval manifest and the card + +### 8.1 The manifest + +The manifest is the structured record the approval card renders. The runner computes it once, at +mint time. + +``` +{ + sourcePath, + itemName, + operation, // add_item | replace_item | set + intent, // "add" | "replace" + totalBytes, + fileCount, + allowExecutableFiles, // the caller's explicit policy + executableFiles: [path], + omitted: [{path, reason, bytes}], + descriptionText, + bodyDigest, + bodyBytes, + files: [{path, bytes, digest, executable}], + contentDigest, + catalogGeneration +} +``` + +Every file carries its own digest. A user who wants to verify one file can do so without the +whole content. + +### 8.2 What the card shows + +The card shows, in this order: + +1. The intent and the item name. "Add skill `pdf-tools`" or "Replace skill `pdf-tools`". +2. The source path. +3. The omission section, when `omitted` is non-empty. This comes before the content, because it + is what the user is most likely to miss. +4. The executable section, when `executableFiles` is non-empty. It names the policy value. +5. The description, in full. +6. The body, or a diff against the current body for a replace. +7. The file list, with sizes. +8. The totals and `contentDigest`. + +### 8.3 Truncation rules + +Long content must not be dropped silently and must not flood the card. + +| Element | Rule | +|---|---| +| Description | Never truncated. It is capped at 1024 code points already. | +| Body, on add | First 4000 code points, then a marker giving the omitted count and `bodyDigest`. | +| Body, on replace | A unified diff, capped at 400 lines. Beyond that, show the changed-line counts and `bodyDigest` for both sides. | +| File list | First 50 files by path order, then a marker giving the remaining count. | +| File content | Not shown on the card. Each file shows its path, size, digest, and executable flag. | +| Omission list | Never truncated. It is capped by the file count already. | + +The card must state, in words the user reads, that `contentDigest` covers the **full** value and +not the truncated view. Without that sentence, a truncated card implies a partial approval. + +The user interface must offer a way to see the full body and any single file's content on +demand. The runner serves that from the frozen value, so what the user reads is what will +commit. + +## 9. Error codes + +| Code | Meaning | +|---|---| +| `source_not_found` | The path does not exist under the import root. | +| `source_escapes_workspace` | The path resolves outside the import root. | +| `source_invalid` | No `SKILL.md`, bad frontmatter, unsafe name, or a malformed path. | +| `source_too_large` | The aggregate cap, the `SKILL.md` cap, or the manifest cap was passed. | +| `source_unsupported_content` | One or more unsupported files, under `on_unsupported: reject`. | +| `source_executable_not_permitted` | An executable file with `allow_executable_files` false. | +| `source_changed_during_read` | The folder changed between the manifest and the read. | +| `source_read_failed` | A daemon or filesystem error. | +| `source_timeout` | The import passed its deadline. | +| `source_cancelled` | The turn aborted. | + +Every code carries the offending paths, up to 20, and a count of the rest. + +## 10. Test obligations + +**Confinement.** +- Traversal, absolute path, backslash, and NUL are refused before any read. +- A symbolic link at the folder root is refused. +- A symbolic link inside the folder is refused, even when its target stays inside the workspace. +- A path outside `imports/` but inside the workspace is refused. + +**TOCTOU.** +- Local: replace a file with a symbolic link between the walk and the open. The open must fail. +- Daytona: change a file's size between the manifest and the read. The import must reject with + `source_changed_during_read`. +- Daytona: change a file's mode between the manifest and the verification pass. The import must + reject. + +**Unsupported content.** +- A binary file rejects the whole import by default. +- The same folder with `on_unsupported: "omit"` imports, lists the omission, and the omission + reaches the card. +- An oversized file behaves the same way. +- An aggregate-cap breach rejects under both `reject` and `omit`. + +**Executable policy.** +- An executable file with no `allow_executable_files` rejects. +- The same folder with `allow_executable_files: true` imports, and `files[].executable` is true. +- `allow_executable_files` is never true when the caller did not ask for it, whatever the mode + bits say. + +**Digest.** +- Two imports of an unchanged folder give the same `contentDigest`. +- Changing one byte in one file changes `contentDigest`. +- The value sent to the API digests to the approved `contentDigest`. + +**Daytona framing.** +- A file whose name holds a newline, a tab, a quote, and a backslash imports correctly. +- A manifest larger than 1 MiB rejects. +- A manifest command that exceeds its timeout rejects and releases every buffer. +- Cancelling the turn mid-import releases every buffer and issues no further daemon calls. + +**Card.** +- A body longer than the truncation cap shows the marker and the digest. +- A folder with 200 files shows 50 and a remaining count. +- The card states that the digest covers the full value. + +## 11. Conflict with `change-set.md` — resolved + +**Status: resolved on 4 August. The team lead accepted this contract's position.** +`contracts/change-set.md` section 5.1 now carries both policy fields, and section 2.1 +lists them in the model-visible catalog schema. The rest of this section stays for the +record. + +`contracts/change-set.md` section 5.1 defined `value_from` as a closed object: + +```json +{ + "type": "object", + "additionalProperties": false, + "required": ["type", "path"], + "properties": { + "type": { "const": "workspace" }, + "path": { "type": "string", "minLength": 1 } + } +} +``` + +This contract adds two fields to that object: `on_unsupported` and `allow_executable_files`. +Under `additionalProperties: false` the model could not write them, so the two contracts +conflict as written. + +The conflict must be resolved before either slice starts. The resolution this contract proposes: + +**Widen the `value_from` schema in `change-set.md` to hold the two policy fields.** + +```json +{ + "type": "object", + "additionalProperties": false, + "required": ["type", "path"], + "properties": { + "type": { "const": "workspace" }, + "path": { "type": "string", "minLength": 1 }, + "on_unsupported": { "enum": ["reject", "omit"], "default": "reject" }, + "allow_executable_files": { "type": "boolean", "default": false } + } +} +``` + +Three reasons this is the right side to change. + +1. Both fields are decisions the caller makes about one import. They belong with the import + declaration, not on the operation and not on the envelope. Another operation in the same + commit may import a different folder with a different answer. +2. The engine never sees them. `change-set.md` already states that the engine refuses + `value_from` with `source_invalid`, and that the runner resolves it first. The runner strips + the whole object and replaces it with `value`. So widening the schema adds no engine surface. +3. The schema is the model-facing catalog schema. The model must be able to write the fields, or + the defaults become the only reachable behavior. Reject-by-default with no way to opt in would + make an ordinary skill folder with one binary asset permanently uncommittable. + +The alternative is to keep `value_from` closed and put the two fields on the operation. This +contract does not recommend it. It separates the policy from the source it governs, and it +breaks when one commit imports two folders with different answers. + +Owner: the engine spike owns `change-set.md`. Both edits are done. `change-set.md` +section 5.1 holds the widened `value_from` schema and points back to sections 4.2, 4.3, +and 5.2 of this contract. `change-set.md` section 2.1 lists both fields in the +model-visible catalog schema and repeats that the runner strips the object. + +## 12. Decisions this contract changes + +| Existing item | Change | +|---|---| +| `decisions.md` open call 5 | Binary files no longer drop with a warning. They reject by default, with `on_unsupported: "omit"` as the explicit opt-in. | +| `decisions.md` open call 6 | The reach is the designated `imports/` root, not the whole workspace. | +| `spikes/runner-spike.md`, "Codec gaps" | `allow_executable_files` is no longer derived. Binary and oversized files no longer drop silently. Symbolic links are no longer followed. | +| `plan.md` | Add the `imports/` root creation to the workspace slice. Add the Daytona reader as its own unit of work. | diff --git a/docs/design/agent-config-editing/decisions.md b/docs/design/agent-config-editing/decisions.md index 5d7e76ecec..368e1ef999 100644 --- a/docs/design/agent-config-editing/decisions.md +++ b/docs/design/agent-config-editing/decisions.md @@ -47,9 +47,25 @@ reasoning is in `spikes/engine-spike.md` (D1-D33, O1-O12) and `spikes/runner-spi - The characterization tests are the contract for the lifecycle refactor: slices 5-7 must edit them deliberately. +## Contract phase (team lead, 4 August, after the NO-GO gate) + +- The six contracts in `contracts/` are the implementation source of truth. They + supersede the matching sections of the two research documents where they differ. +- Fail-closed defaults adopted from the gate review: every `value_from` import comes + from the `imports/` root; unsupported files reject the whole import unless the caller + opts into omission; executable permission is caller-declared and default-deny, never + derived from file mode bits; frozen approval bytes never ride tool arguments; a cold + resume refuses the old approval and raises a new gate. +- Arbitration: the `value_from` object gains two optional import-policy fields + (`on_unsupported`, `allow_executable_files`). The runner consumes and strips them; + the engine never sees them. change-set.md §5.1 and workspace-import.md §11 + cross-reference this. +- Inline resolution happens only on an explicit allow verdict from the permission + plan; a missing authorization for a gated call fails closed. + ## Product calls confirmed by Mahmoud -(Empty. The seven open calls below move here once answered.) +(Empty. The open calls below move here once answered.) ## Open product calls (waiting on Mahmoud) @@ -73,4 +89,21 @@ reasoning is in `spikes/engine-spike.md` (D1-D33, O1-O12) and `spikes/runner-spi designated subfolder only? Recommended: whole workspace; the approval manifest is the control. 7. **Pi tool removal means hidden (runner Q5).** The tool stays registered but the - model cannot see or call it. Recommended: accept. + model cannot see or call it. The runner additionally drops the execution binding, so + a hidden tool cannot run even if called. Recommended: accept with that invariant. +8. **Force a gate on every value_from import?** The gate review says always gate; the + contracts implement the narrower rule (inline resolution only on an explicit allow + verdict). Recommended: keep the narrower rule; it gates by default and respects an + explicit allow policy. +9. **Cold resume after a value_from approval.** The frozen bytes die with the + environment. The contract asks again (a second approval prompt) instead of + persisting the bytes durably. Recommended: ask again; durable persistence recreates + the large-payload storage problem. +10. **May the agent change its own harness.kind?** It is the most identity-defining + field and forces a sandbox rebuild. Recommended: no; human commit only. +11. **May the agent write parameters outside the agent subtree?** Nothing else lives + there today for agent workflows. Recommended: no; scope commits to + parameters.agent. +12. **Store the authored operations for audit?** The RFC promises the diff as a commit + artifact; the plan currently drops it. Recommended: store the operations list on + the revision commit record; it is small and makes agent commits reviewable. diff --git a/docs/design/agent-config-editing/status.md b/docs/design/agent-config-editing/status.md index 8a80ff35f9..a601eef53f 100644 --- a/docs/design/agent-config-editing/status.md +++ b/docs/design/agent-config-editing/status.md @@ -26,7 +26,14 @@ unspecified; read_config, the editable-scope policy, and the description field n real contracts; live tool routes need an applied-generation acknowledgement; the slice plan understates dependencies and slices 1, 3, 7 are too big. -Response: the team writes the missing contracts before any slice starts. engine-spike +Update: all six contracts are written in `contracts/` (change-set, commit-transaction, +read-config from engine-spike; execution-authorization, workspace-import, +adapter-matrix from runner-spike). The one cross-contract conflict (the value_from +schema) is arbitrated and recorded in `decisions.md`. The second gate review runs now. +Twelve product calls are open for Mahmoud in `decisions.md`. + +Original response for the record: the team writes the missing contracts before any +slice starts. engine-spike owns the change-set contract, the commit transaction and response, and the read_config contract. runner-spike owns the execution authorization, the workspace import boundary, and the corrected adapter matrix. Second gate review after that. Fail-closed From eeb692d35d237ff485d3118e96cbb5e79d1ad059 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Tue, 4 Aug 2026 21:56:40 +0200 Subject: [PATCH 05/36] docs(design): gate 2 NO-GO recorded; slice plan rewritten; product calls deduped to six --- docs/design/agent-config-editing/decisions.md | 69 ++++++-------- docs/design/agent-config-editing/plan.md | 51 ++++++++-- .../research/design-gate2-review-codex.md | 94 +++++++++++++++++++ 3 files changed, 166 insertions(+), 48 deletions(-) create mode 100644 docs/design/agent-config-editing/research/design-gate2-review-codex.md diff --git a/docs/design/agent-config-editing/decisions.md b/docs/design/agent-config-editing/decisions.md index 368e1ef999..763deb726f 100644 --- a/docs/design/agent-config-editing/decisions.md +++ b/docs/design/agent-config-editing/decisions.md @@ -67,43 +67,36 @@ reasoning is in `spikes/engine-spike.md` (D1-D33, O1-O12) and `spikes/runner-spi (Empty. The open calls below move here once answered.) +## Settled by the contracts (no longer open) + +- Binary and unsupported files reject the whole import by default; `on_unsupported: + "omit"` is the explicit opt-in. (Was open call 5.) +- Imports come from the designated `imports/` root, not the whole workspace. (Was open + call 6.) +- Cold resume refuses the old approval and asks again; frozen bytes are not persisted + durably. (Was open call 9.) +- Pi tool removal hides the tool AND drops the runner execution binding; hidden-only + never ships. (Was open call 7.) +- Embedded skills stay unaddressable in v1; support can be added later without a + breaking change. (Was open call 3.) + ## Open product calls (waiting on Mahmoud) -1. **Storage normalization (engine O2, O3).** Normalize configuration strings once on - write: Unicode to NFC, line endings to LF. Exact matching then stays honest. - Recommended: yes. Risk: stored bytes change on the next write of old fields. -2. **Unique-name enforcement and old configurations (engine O7).** A configuration that - already holds a duplicate name would become uncommittable under a global check. - Recommended: enforce per commit only for collections the commit touches, warn on the - rest, and file a cleanup migration separately. -3. **Embedded skills stay unaddressable in v1 (engine O6).** An agent with an - `@ag.embed` skill cannot edit it by name; it must use a whole-list `set`. - Recommended: accept for v1, design a stable embed key later. -4. **Ungated `value_from` (runner Q1).** When the run's permission policy raises no - approval gate, the folder content is committed without a human seeing it. - Recommended: follow the run's policy (no forced gate); the policy owner opted out. -5. **Binary files in skill folders are dropped with a warning (runner Q3).** A skill - with a PNG or a compiled helper loses that file in v1. The eventual fix is a blob - `uri` file variant. Recommended: accept for v1, flag in the approval card. -6. **`value_from` reach (runner Q4).** Any path under the workspace root, or a - designated subfolder only? Recommended: whole workspace; the approval manifest is - the control. -7. **Pi tool removal means hidden (runner Q5).** The tool stays registered but the - model cannot see or call it. The runner additionally drops the execution binding, so - a hidden tool cannot run even if called. Recommended: accept with that invariant. -8. **Force a gate on every value_from import?** The gate review says always gate; the - contracts implement the narrower rule (inline resolution only on an explicit allow - verdict). Recommended: keep the narrower rule; it gates by default and respects an - explicit allow policy. -9. **Cold resume after a value_from approval.** The frozen bytes die with the - environment. The contract asks again (a second approval prompt) instead of - persisting the bytes durably. Recommended: ask again; durable persistence recreates - the large-payload storage problem. -10. **May the agent change its own harness.kind?** It is the most identity-defining - field and forces a sandbox rebuild. Recommended: no; human commit only. -11. **May the agent write parameters outside the agent subtree?** Nothing else lives - there today for agent workflows. Recommended: no; scope commits to - parameters.agent. -12. **Store the authored operations for audit?** The RFC promises the diff as a commit - artifact; the plan currently drops it. Recommended: store the operations list on - the revision commit record; it is small and makes agent commits reviewable. +Six distinct decisions. The first five block their implementation slices. + +1. **Storage normalization** (blocks S1a/S1b). Normalize configuration strings once on + write (Unicode NFC, line endings LF), or preserve exact bytes? The second gate + review recommends exact bytes in v1. My earlier recommendation was normalize. + Mahmoud decides; the engine's matching and the migration story follow. +2. **Unique-name enforcement** (blocks S1b validation). The contract proposes: a commit + must not introduce a new duplicate, a touched collection must end clean, untouched + legacy duplicates only warn. Confirm or change. +3. **Import gating rule** (blocks S3b). Always force an approval gate on `value_from`, + or gate by default with inline resolution only on an explicit allow verdict from the + permission plan? The contracts implement the second. (Merges former calls 4 and 8.) +4. **May the agent change its own `harness.kind`?** (blocks S2's scope section.) + Recommended: no; human commit only. +5. **May the agent write `parameters` outside the `agent` subtree?** (blocks S2's + scope section.) Recommended: no. +6. **Store the authored operations for audit** (blocks S1b persistence design). + Recommended: yes, on the revision commit record. diff --git a/docs/design/agent-config-editing/plan.md b/docs/design/agent-config-editing/plan.md index a50bffac6f..9b7b256d94 100644 --- a/docs/design/agent-config-editing/plan.md +++ b/docs/design/agent-config-editing/plan.md @@ -32,22 +32,53 @@ Codex review of the finalized design. ## Phase 2: vertical slices -Slices 1 to 4 are API-and-frontend work (engine-spike). Slices 5 to 7 are runner work -(runner-spike). The two tracks run in parallel; they touch disjoint files. +The contracts in `contracts/` are the source of truth for every slice. The two tracks +(API, runner) run in parallel but are NOT fully disjoint: the import slices touch +runner approval and parked-state code that the lifecycle slices later refactor. The +sequencing below respects that: import authorization (S3b) lands before the coordinator +extraction (S6) rebases it, or waits for it, whichever is ready first; the team lead +sequences the merge order at that point. -| Slice | Content | User stories served | +One ordering rule stands above the table: ordered operations do not become +model-visible in the catalog until `read_config` exists. An agent that can be told +"read, then edit" but cannot read would fail every conflict retry. + +| Slice | Content | Blocked by | |---|---|---| -| 1 | Change-set engine + commit wrapper: ordered operations, base check atomic with the insert (409 with both ids), commit validation, unique names, strict DTOs, catalog schema. | US-1, US-2, US-4, US-7 | -| 2 | `read_config` tool: self-bound revision read, partial reads, revision id + draft flag in the response, shaped output. | US-5, US-7 retry loop | -| 3 | `value_from` workspace path end to end: runner resolution, folder-to-skill codec, frozen approval content, minimal approval card (name, file list, diff). | US-3 | -| 4 | Optional agent-written `description` on builder tool calls, shown on tool cards. | R12 | -| 5 | Runner safety fixes + applied-state identity (lifecycle migration steps 1 and 2): revision id out of the fingerprint, teardown stops instead of deleting where safe, environment owns applied state, approval-stale-config bug structurally dead. | US-8 | -| 6 | Coordinator extraction + shadow routing (migration steps 3 and 4). Behavior unchanged; the new router runs in shadow and logs disagreements. | US-8 | -| 7 | Lifecycle split + in-place routes (migration steps 5 to 8): workspace refresh with deletions, setModel, Codex mode, session reopen for Claude/Codex tool changes, runtime restart for Pi tool changes, credential refresh so Daytona keys never rebuild. | US-8 | +| S1a | The pure engine and the operation schemas, per `contracts/change-set.md` §12's prototype changes. No catalog exposure. | nothing | +| S1b | The commit transaction per `contracts/commit-transaction.md`: one transaction, base check, validation, canonical equality over all persisted fields, no-change response. | product calls 1, 2, 12 | +| S2 | `read_config` per `contracts/read-config.md`, including the editable-scope policy. | product calls 10, 11 for the scope section | +| S3a | The import codec and workspace readers per `contracts/workspace-import.md`. Pure, both platforms. | nothing | +| S3b | The single-use execution authorization per `contracts/execution-authorization.md`, wired into the approval gate. | product call 4 | +| S3c | The approval card: manifest, sizes, digests, diff, executable flags. Minimal frontend. | S3b | +| S4 | The ephemeral `description` on builder tool-call envelopes, shown on tool cards. | nothing | +| S5 | Runner safety + applied-state identity (lifecycle migration steps 1-2). | nothing | +| S6 | Coordinator extraction + shadow routing (steps 3-4). | S5 | +| S7a | Lifecycle extraction into units (step 5), behavior unchanged. | S6 | +| S7b | In-place routes for workspace files and model (step 6, first half). | S7a | +| S7c | Tool-catalog routes with the trusted acknowledgement channel per `contracts/adapter-matrix.md`. | S7a, spike S2 verdicts | +| S7d | MCP reopen with positive native-history verification. | S7a | +| S7e | Credential and provider reconciliation, including the Daytona creation-identity split (steps 8-9). | S7a | QA gates: the qa teammate tests each slice when it lands (unit suites plus live stories on the dev stack). A regression blocks the slice until fixed. +### Rollout and compatibility + +- **Deployment order:** API first (it accepts both delta forms), then the SDK catalog + (it advertises the new schema), then runner images. Each step is backward-compatible + with the previous one. +- **Kill switch:** one API-side setting disables the ordered delta form and + `value_from` acceptance; the catalog reads it and falls back to advertising the + legacy schema. The runner needs no switch of its own: without the catalog schema, no + model emits the new form. +- **Legacy DTO compatibility:** `extra="forbid"` applies to the new operations form + only. The legacy `set`/`remove` form keeps its current tolerance, so old playbooks + and stored callers do not start failing. +- **Cross-language fixtures:** the canonical tool key and the canonical JSON + serialization get golden fixtures shared by the Python engine and the TypeScript + runner, so the two implementations cannot drift silently. + ## Phase 3: finalization Codex code review over the full diff; fix findings. `/write-pr-description` for each diff --git a/docs/design/agent-config-editing/research/design-gate2-review-codex.md b/docs/design/agent-config-editing/research/design-gate2-review-codex.md new file mode 100644 index 0000000000..6f8a0428bd --- /dev/null +++ b/docs/design/agent-config-editing/research/design-gate2-review-codex.md @@ -0,0 +1,94 @@ +# Verdict: NO-GO + +The contracts substantially improve the design, but they do not clear the second gate. Item 1 is resolved. Items 2 through 6 remain partial. Item 7 was not done. Item 8 is only partially done. + +The live-code premises from the first review were correct. The remaining blockers are in the contracts themselves. + +## Gate items 1 through 8 + +| Item | Status | Assessment | +|---|---|---| +| 1. Authoritative change-set contract | **RESOLVED** | [change-set.md §2-12](/home/mahmoud/code/agenta-2/docs/design/agent-config-editing/contracts/change-set.md:25) settles the envelope, two delta forms, target grammar, seven operations, `value_from`, `match_mode`, parent creation, overlap counting, warnings, unique-name behavior, scope, validation, and errors. §12 accurately lists prototype deviations. Product call 2 can still change the unique-name rule, but the original contradictions are gone. | +| 2. Atomic commit and no-change response | **PARTIAL** | [commit-transaction.md §3, §6, §7, §9](/home/mahmoud/code/agenta-2/docs/design/agent-config-editing/contracts/commit-transaction.md:50) correctly specifies lock, stale-base precedence, response shape, and no events or invalidation. Two correctness gaps remain: §4 omits the existing non-embeddable-reference check, and §5 claims flags are canonicalized while its algorithm compares only `data`. | +| 3. `read_config`, draft behavior, scope, description | **PARTIAL** | [read-config.md §3-12](/home/mahmoud/code/agenta-2/docs/design/agent-config-editing/contracts/read-config.md:47) provides most of the missing contract. It cannot implement its own draft response, however: the catalog binds only `workflow_variant_id`; no draft flag or run revision reaches the endpoint. It also contradicts itself about draft commits: §4 tells the model to copy `base_revision_id`, while §10 says the runner fills it “from the read” without defining state for that. Calls 10 and 11 also leave the security scope unfinished. | +| 4. Single-use execution authorization | **PARTIAL** | [execution-authorization.md §2-7](/home/mahmoud/code/agenta-2/docs/design/agent-config-editing/contracts/execution-authorization.md:34) has the right record, lifecycle, frozen store, expiry, and cold-resume policy. But §2.3 reuses a serializer that deliberately treats a JSON-looking string as an object or array. That is not an exact argument binding and permits digest collisions between semantically different arguments. Multi-source calls also lack atomic verify-and-consume semantics. | +| 5. Safe, lossless workspace import | **PARTIAL** | [workspace-import.md §2-10](/home/mahmoud/code/agenta-2/docs/design/agent-config-editing/contracts/workspace-import.md:21) resolves the default policy, limits, manifest, executable handling, and Daytona framing. Its local confinement claim is false: `O_NOFOLLOW` protects only the final path component, not a replaced intermediate directory. Its Daytona root check is also self-contradictory: §2 requires a named folder below `imports/`, but §6.2 requires that folder’s real path to equal the `imports/` root. That would reject every valid import. | +| 6. Applied-generation lifecycle invariant | **PARTIAL** | [adapter-matrix.md §1-8](/home/mahmoud/code/agenta-2/docs/design/agent-config-editing/contracts/adapter-matrix.md:10) restores acknowledgement, generation coupling, transport-specific capability lookup, Pi execution revocation, and continuity verification. But the proposed Pi acknowledgement is another record in the sandbox-writable relay directory. It is forgeable under the same threat model that motivated the authorization contract. The generation inputs are also undefined for execution-plan-only changes. | +| 7. Rewrite the slice plan | **UNRESOLVED** | [plan.md §Phase 2](/home/mahmoud/code/agenta-2/docs/design/agent-config-editing/plan.md:33) is unchanged. It still claims disjoint tracks, still combines engine and transaction work in slice 1, all `value_from` work in slice 3, and lifecycle steps 5-8 in slice 7. It still omits lifecycle step 9. The contracts themselves say the plan still needs updating. | +| 8. Rollout and test gates | **PARTIAL** | The contracts now specify most requested tests: two-writer races, no-change races, forged calls, Daytona import cases, real harness behavior, and failed reconciliation. Missing are the mixed-version API/SDK/catalog/runner deployment order, a concrete kill switch, and a compatibility path for legacy DTOs when `extra="forbid"` starts rejecting previously ignored fields. These are still absent from `plan.md`. | + +## Live-code verification + +The contracts’ original code claims check out: + +- [service.py](/home/mahmoud/code/agenta-2/api/oss/src/core/workflows/service.py:1852) resolves the delta before DAO insertion. It also performs `_reject_non_embeddable_workflow_embeds` at [service.py](/home/mahmoud/code/agenta-2/api/oss/src/core/workflows/service.py:1884), which the new transaction pseudocode omits. +- [dao.py](/home/mahmoud/code/agenta-2/api/oss/src/dbs/postgres/git/dao.py:1565) opens its own transaction and suppresses all but `InitialRevisionConflict`. The existing row lock is initial-only at [dao.py](/home/mahmoud/code/agenta-2/api/oss/src/dbs/postgres/git/dao.py:1606). Version calculation and storage currently happen in further sessions. +- [relay-guard.ts](/home/mahmoud/code/agenta-2/services/runner/src/engines/sandbox_agent/relay-guard.ts:14) explicitly passes non-Pi `ask` calls without a runner grant. +- [server.ts](/home/mahmoud/code/agenta-2/services/runner/src/server.ts:856) really falls back cold on approval mismatch, empty decisions, and resume failure. +- [run-turn.ts](/home/mahmoud/code/agenta-2/services/runner/src/engines/sandbox_agent/run-turn.ts:822) mixes acquire-time `plan.tools.toolSpecs` with incoming callback/context. +- [environment.ts](/home/mahmoud/code/agenta-2/services/runner/src/engines/sandbox_agent/environment.ts:1061) treats matching session IDs as proof of continuity. + +## New blocking problems + +1. **`argsDigest` is not an exact digest.** + [execution-authorization.md §2.3](/home/mahmoud/code/agenta-2/docs/design/agent-config-editing/contracts/execution-authorization.md:71) mandates the existing `canonicalJson`. Its `normalizeJsonish` behavior in [responder.ts](/home/mahmoud/code/agenta-2/services/runner/src/responder.ts:124) makes these hash identically: + + ```json + {"value": "{\"x\":1}"} + {"value": {"x": 1}} + ``` + + Ordered-operation `value` fields are arbitrary JSON, so this is a real same-ID argument-substitution hole. Authorization needs a strict canonical JSON serializer that preserves JSON types and performs no replay normalization. + +2. **Authorization for a multi-source commit is underspecified.** + One commit may resolve eight sources, but §3.2 and §3.3 verify and delete one authorization at a time. The runner must verify every required record, then consume the complete set atomically, then substitute all frozen values. A denied permission verdict must stop before any workspace read. + +3. **The local TOCTOU protection does not work as claimed.** + `fs.opendir` plus `open(path, O_NOFOLLOW)` is not an `openat`-style walk. Replacing an intermediate directory with a symlink can redirect the final open outside the import root. The contract needs a real fd-relative traversal, a trusted snapshot helper, or an explicitly weaker threat model. + +4. **The Daytona confinement test is both incorrect and still raceable.** + [workspace-import.md §6.2](/home/mahmoud/code/agenta-2/docs/design/agent-config-editing/contracts/workspace-import.md:257) must test “descendant of resolved import root,” not equality. The two manifest passes also cannot prevent a symlink or same-size content from being swapped in for the read and swapped back before verification. Under an adversarial-sandbox threat model, this is not an adequate snapshot. + +5. **`read_config` cannot compute `is_draft`.** + [read-config.md §2](/home/mahmoud/code/agenta-2/docs/design/agent-config-editing/contracts/read-config.md:22) binds only the variant ID. The SDK knows `workflow.is_draft`, but that information never reaches the new endpoint. Add a hidden run-context binding or remove the draft claim. + +6. **Commit equality is defined over the wrong persisted payload.** + [commit-transaction.md §4-5](/home/mahmoud/code/agenta-2/docs/design/agent-config-editing/contracts/commit-transaction.md:112) computes `flags`, then canonicalizes only `data`. Flags are stored separately. Canonical equality must cover every persisted behavior-bearing field, and the checked build must retain `_reject_non_embeddable_workflow_embeds`. + +7. **The lifecycle acknowledgement channel is untrusted.** + [adapter-matrix.md §4.3](/home/mahmoud/code/agenta-2/docs/design/agent-config-editing/contracts/adapter-matrix.md:198) puts Pi acknowledgements in the sandbox-writable relay directory. A forged acknowledgement can advance applied state. It needs a trusted response channel or authentication unavailable to arbitrary sandbox processes. + +8. **Generation semantics are incomplete.** + `catalogGeneration` changes when the model-visible catalog changes, but authorizations also depend on execution-plan semantics such as permission, context binding, dispatch target, and timeout. Define the canonical generation payload, including stable execution semantics while excluding rotating credentials. + +9. **The approval manifest cannot describe every allowed operation.** + `change-set.md` permits `value_from` on `set`, but [workspace-import.md §8](/home/mahmoud/code/agenta-2/docs/design/agent-config-editing/contracts/workspace-import.md:360) supports only add/replace intent and skill-centric diffs. Either disallow `set + value_from` or define its target constraints and approval presentation. + +10. **The interface-role pass found a policy conflation.** + `value_from` mixes source routing (`type`, `path`) with import policy, and `allow_executable_files` simultaneously acts as an import grant and a persisted skill runtime capability. Those are different owners and lifecycles. Separate import policy from the stored skill capability, or explicitly define why one model-authored value controls both. + +11. **`decisions.md` is internally stale.** + Its contract-phase section says designated-root, reject-by-default, and cold reapproval were adopted. Its “open” calls 5, 6, and 9 still propose or question the opposite. Calls 4 and 8 are the same decision. The file currently lists 12 calls but only 11 distinct questions. + +## Product calls + +No product call prevents an isolated engine-helper or lifecycle-extraction PR. The following do block their affected implementation slices: + +| Call | Classification | +|---|---| +| 1. Storage normalization | **Blocks engine and transaction work.** It changes exact matching, stored bytes, canonical equality, and migration behavior. | +| 2. Unique-name enforcement | **Blocks engine validation.** It changes which legacy configurations remain committable. | +| 3. Embedded skills | Can be answered during implementation. The v1 unaddressable behavior is isolated and later support can be additive. | +| 4. Ungated `value_from` | **Blocks authorization enablement.** This is one decision together with call 8. It does not block the pure codec. | +| 5. Binary files | Does not block. The contract has already chosen reject-by-default with explicit omission. Remove it from the open list. | +| 6. Workspace reach | Does not block as a product call. The contract chose `imports/`. The technical confinement design still blocks. | +| 7. Pi hidden removal | Can be answered during implementation. The safe fallback is restart; hidden-only must never ship without execution-plan revocation. | +| 8. Force every import gate | Duplicate of call 4. Resolve once before authorization integration. | +| 9. Cold resume | Does not block. The contract chose fail-closed reapproval. Durable persistence can be a later optimization. | +| 10. `harness.kind` | **Blocks editable-scope enablement.** This is an identity and rebuild boundary. | +| 11. Parameters outside `agent` | **Blocks editable-scope enablement.** The current contract allows more than the recommendation. | +| 12. Store authored operations | **Blocks commit persistence design.** Decide before landing the transaction wrapper, or accept an immediate schema migration and missing audit history. | + +The six independent blocking decisions are 1, 2, 4/8, 10, 11, and 12. + +There is no GO resequencing to mandate. Item 7 itself remains unresolved, so the next gate should review an actually rewritten plan alongside corrected contracts. From 86ac5e32e6dde91306fa7adc046d50709bcde076 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Tue, 4 Aug 2026 22:09:13 +0200 Subject: [PATCH 06/36] docs(design): gate 2 fixes across all six contracts; arbitrations recorded Strict canonical serializer, multi-source atomicity, honest TOCTOU model, descendant confinement, harmless-forgery acknowledgement design, canonical generation payload, text-file import into text fields with diff approval, four-layer executable permission split. --- .../contracts/adapter-matrix.md | 219 ++++++- .../contracts/change-set.md | 231 ++++++-- .../contracts/commit-transaction.md | 134 ++++- .../contracts/execution-authorization.md | 236 +++++++- .../contracts/read-config.md | 172 +++++- .../contracts/workspace-import.md | 542 +++++++++++++++--- docs/design/agent-config-editing/decisions.md | 19 + 7 files changed, 1379 insertions(+), 174 deletions(-) diff --git a/docs/design/agent-config-editing/contracts/adapter-matrix.md b/docs/design/agent-config-editing/contracts/adapter-matrix.md index 5701af2da1..a8b0de20bd 100644 --- a/docs/design/agent-config-editing/contracts/adapter-matrix.md +++ b/docs/design/agent-config-editing/contracts/adapter-matrix.md @@ -103,7 +103,95 @@ private metadata. That is the seam to build on. Both objects carry `catalogGeneration`. The turn runner receives both as one unit, freshly built from the incoming request. It must not read either from `env.plan`. -### 2.4 `customTools` and the fingerprint +### 2.4 The canonical generation payload + +Gate 2 is right that "changes when the model-visible catalog changes" is not a definition. An +authorization minted under generation N also depends on execution semantics the model never +sees. Those must be in the generation, or the authorization's generation check does not mean +what `execution-authorization.md` §8 claims. + +`catalogGeneration` is SHA-256 over the strict canonical serialization of one document. It uses +`strictCanonicalJson` from `execution-authorization.md` §2.3.3, not the lenient serializer. + +#### 2.4.1 The document + +``` +{ + version: 1, + tools: [ ... one entry per tool, sorted by name ... ] +} +``` + +Each entry holds exactly these fields, in this order: + +| Field | Source | Why it is in the generation | +|---|---|---| +| `name` | The canonical tool key | Identity. | +| `description` | Public metadata | The model chooses on it. | +| `inputSchema` | Public metadata | The model's arguments are validated against it. | +| `readOnly` | Public metadata | The model and the permission policy both read it. | +| `permission` | Execution plan | Whether a call gates. Changing it changes what an approval means. | +| `dispatchKind` | Execution plan | `direct`, `gateway`, `client`, or `relay`. It decides which code path runs the call. | +| `dispatchTarget` | Execution plan | For a direct call, the method plus the path template. For a gateway call, the `callRef`. This is WHERE the call goes. | +| `contextBindingPaths` | Execution plan | The sorted list of bound argument paths. Not their values. A new binding changes which argument the runner overwrites. | +| `argsIntoPath` | Execution plan | Where the model's arguments land in the body. | +| `staticBodyDigest` | Execution plan | A digest of the server-fixed `body` fields. It captures a changed fixed field without copying it. | +| `timeoutMs` | Execution plan | A behavior change the approver could reasonably care about. | + +The list is sorted by `name`. The set of tools is part of the document, so adding or removing a +tool changes the generation even when no surviving tool changed. + +#### 2.4.2 What is deliberately excluded + +| Excluded | Reason | +|---|---| +| Callback authorization | It is per-turn credential material. It rotates on an ordinary turn. Including it would change the generation constantly and would invalidate every parked authorization for no security gain. | +| Gateway or MCP credential values | Same reason. Credentials have their own subsystem and their own epoch comparison. | +| The callback ENDPOINT | Borderline. It is routing, not a credential, and it is already in `configFingerprint`. An endpoint change today evicts the session, so it cannot silently change under a parked authorization. Revisit this if the endpoint ever leaves the fingerprint. | +| `runContext` values | Per-turn data. The BINDING PATHS are included; the values are not. | +| Trace and telemetry identifiers | Per-turn. Never behavior. | +| Tool ordering as delivered | The document sorts, so a reordered input does not churn the generation. | + +The rule behind the split: **include what changes the meaning of a call; exclude what rotates.** +A field that changes on an ordinary turn must not be in the generation, or parked authorizations +die for no reason and users re-approve constantly. + +#### 2.4.3 Execution-plan-only changes + +This is the case gate 2 named as undefined. A change to `permission`, `dispatchTarget`, +`contextBindingPaths`, `argsIntoPath`, `staticBodyDigest`, or `timeoutMs` changes the generation +even though the model-visible catalog is byte-identical. + +Two consequences follow, and both are intended. + +1. **Parked authorizations minted under the old generation fail closed.** The human approved a + call that would have gone to one place with one permission. It would now go elsewhere. That + approval is stale, and `execution-authorization.md` §3.2 refuses it. +2. **The harness needs no reconciliation.** Nothing the model sees changed, so there is nothing + to install and nothing to acknowledge. The runner advances the generation locally and + continues. It must not reopen the session for this. + +So the reconciliation router reads the generation change and then asks a second question: did +the model-visible part change? If yes, run the harness route from section 1.3. If no, advance +the generation and skip straight to the turn. + +This makes the generation serve two consumers correctly. Authorizations need every +behavior-bearing field. Harness reconciliation needs only the model-visible subset. One value +with an explicit split is simpler than two values that can disagree. + +#### 2.4.4 Test obligations + +- Changing only `timeoutMs` changes the generation, fails a parked authorization, and does NOT + reopen the harness session. +- Changing only `description` changes the generation and DOES run the harness route. +- Rotating the callback authorization does NOT change the generation, and a parked + authorization still verifies. +- Reordering the input tool list does NOT change the generation. +- Adding a tool changes the generation even when every existing tool is unchanged. +- Changing a `contextBindings` VALUE source without changing its PATH does not change the + generation. Changing the path does. + +### 2.5 `customTools` and the fingerprint **`customTools` leaves `configFingerprint` only in the same change that makes the catalog and the execution plan atomic.** @@ -126,6 +214,8 @@ The order is therefore fixed: Steps 1 to 3 change no reuse behavior. Step 4 is the only behavior change, and by then the foundation exists. +Section 2.4 defines the generation these steps compute. + ## 3. The corrected matrix Capability is keyed by harness, adapter version, **and transport or provider**. A single value @@ -195,15 +285,95 @@ blocking execution invariant, and this contract adopts it. If either half cannot be confirmed, Pi removal escalates to `restart-runtime`. -### 4.3 Pi acknowledgement +### 4.3 Pi acknowledgement, and the channel it needs The extension hook returns the result of `getActiveTools()` after the refresh. The runner compares that set to the desired active set. Equality acknowledges the generation. Any difference fails the reconciliation and escalates to restart. -The return path needs a channel. The extension can write the acknowledgement into the relay -directory, which the runner already polls. Design it as an ordinary relay record with a reserved -name, so no new transport is needed. +The gate 1 version put that acknowledgement in the relay directory. That was wrong, and gate 2 +is right to reject it. The relay directory is sandbox-writable. It is the exact surface that +motivated the whole execution-authorization contract. A forged acknowledgement there would +advance applied state. + +#### 4.3.1 There is no fully trusted channel from inside a Daytona sandbox + +State this plainly, because it shapes every option below. + +On Daytona the Pi extension runs inside the sandbox. So does the Claude stdio shim. Any message +either one sends is a message from the untrusted party. Signing it does not change that: the +signing key must also live inside the sandbox to be usable, and a process that already runs +arbitrary code there can read it. + +The read-once file pattern the runner already uses for the OTLP bearer +(`writeOtlpAuthFile` in `services/runner/src/engines/sandbox_agent/pi-assets.ts`, mode `0600`) +raises the cost of a forgery. It does not remove it. A same-user process in the sandbox can read +the file before the extension does. + +So the design must not depend on a trusted acknowledgement. It must make a forged one harmless. + +#### 4.3.2 Make the blast radius small, then authenticate what is left + +Three runner-side rules bound what a forged acknowledgement can achieve. All three already exist +elsewhere in this contract set. This section makes them load-bearing. + +1. **The execution plan is runner-side and authoritative.** Section 2.3 keeps + `ToolExecutionPlan` in runner memory. Section 4.2 half 2 makes the relay refuse a tool absent + from the current generation's plan. So a forged acknowledgement can never make a removed tool + executable. +2. **The authorization checks the generation runner-side.** `execution-authorization.md` §3.2 + compares `catalogGeneration` against the runner's own live value, not against anything the + sandbox reports. +3. **Security-relevant changes never take the apply-live route.** Section 1.4 already exempts + permission tightening and credential revocation. Add harness permission files to that list. + These always reopen or restart, and they never wait on an acknowledgement. + +With those three, the worst a forged acknowledgement achieves is a **stale model-visible +catalog**: the model still sees a removed tool it cannot call, or does not yet see an added one. +That is a correctness and user-experience defect. It is not a privilege escalation. + +A fourth rule bounds it further: + +4. **Applied state may only advance to the generation the runner is currently installing.** The + runner holds one pending generation per reconciliation. An acknowledgement can only confirm + that pending value. It can never name a generation of its own. So a forged message cannot + invent a state; it can only claim that a change the runner already decided to make did + happen. + +#### 4.3.3 The channel + +Given the above, the acknowledgement channel is defined as follows. + +- **Not the relay directory.** The relay directory is swept, sandbox-writable, and already + carries execution records. Mixing a control-plane message into it invites exactly the + confusion gate 2 flagged. Use a dedicated path the relay sweep never touches. +- **Per-reconciliation nonce.** The runner mints a fresh random nonce for each reconciliation + and delivers it through the read-once `0600` file pattern above. The acknowledgement must echo + it. The nonce is single-use and expires with the reconciliation deadline. +- **The acknowledgement carries** the nonce, the pending generation identifier, and a digest of + the active tool set. The runner checks all three. +- **Local runs are genuinely trusted.** On a local run the extension executes on the runner host + under the runner's own user. The file channel there is as trustworthy as the runner. The + distinction is worth recording, because local is where this can be tested honestly. + +#### 4.3.4 The stronger option, if it can be built + +The best acknowledgement is a runner-observed effect, not a message. For Pi the candidate is a +runner-side observation of what the harness actually advertises on its next prompt. If Pi's ACP +surface can be made to report its active tool set to the client, the runner reads it directly and +needs no message from inside the sandbox at all. + +This contract does not assume that surface exists. The spike did not find one. Record it as the +target design, and use section 4.3.3 until it exists. + +#### 4.3.5 The same reasoning applies to Claude + +Section 5.3 makes the Daytona stdio shim the acknowledger. The shim also runs inside the sandbox, +so it inherits everything above. It uses the same nonce channel and the same four bounding rules. + +Claude's acknowledgement is slightly stronger in one respect: the shim reports that the harness +issued a `tools/list`, which is an event the shim observes rather than a state it asserts. A +forged report still only confirms the pending generation. ### 4.4 Pi and MCP @@ -357,6 +527,20 @@ generation mismatches. The rollout must be able to fall back before users see a - Applied state never advances after a failed action. This is the partial-reconciliation test the gate review requires. +**Acknowledgement channel.** +- An acknowledgement written to the RELAY directory is ignored. The runner must not read control + messages from the relay dir at all. +- An acknowledgement with no nonce, a stale nonce, or a reused nonce is refused. +- An acknowledgement naming a generation the runner is NOT currently installing is refused. This + is bounding rule 4 in §4.3.2. +- A forged acknowledgement for the pending generation, accepted at face value, still cannot make + a removed tool executable. Assert on the relay refusal, not on the acknowledgement. +- A forged acknowledgement cannot install a loosened permission. Permission tightening never + takes the apply-live route, per §1.4 and bounding rule 3. + +**Generation payload.** +- The six tests in §2.4.4. + **One generation.** - A turn's advertised catalog and its relay execution plan always report the same generation. - A tool removed in generation N+1 cannot execute through the relay, even when the harness still @@ -393,10 +577,33 @@ not close the gate. - `decisions.md`, the runner-spike tools-discovery block. Replace the four verdict lines with section 3's matrix, and add the acknowledgement invariant. -- `decisions.md`, the `customTools` prerequisite line. Replace it with section 2.4's order. +- `decisions.md`, the `customTools` prerequisite line. Replace it with section 2.5's order. - `decisions.md`, open product call 7. Pi removal by hiding is accepted only with section 4.2's execution invariant. - `research/runner-lifecycle-codex.md`, section 3's adapter matrix and the `HarnessLifecycleCapabilities` type. The capability key needs transport and provider. - `spikes/runner-spike.md`, the Part 2 verdict table and summary block. - `plan.md`. Split slice 7 as must-fix item 7 requires, and adopt section 8's order. + +## 11. Gate 2 resolution + +| Gate 2 point | Where it is answered | +|---|---| +| New problem 7: the Pi acknowledgement channel is sandbox-writable and forgeable | §4.3 is rewritten. §4.3.1 states that NO fully trusted channel exists from inside a Daytona sandbox, and explains why signing does not fix it. §4.3.2 makes a forged acknowledgement harmless with four runner-side rules, so the worst case is a stale model-visible catalog and never a privilege escalation. §4.3.3 defines the channel: off the relay directory, single-use nonce over the existing read-once `0600` file pattern, echoed with the pending generation and an active-set digest. §4.3.4 names the stronger runner-observed design as the target. §4.3.5 applies the same reasoning to the Claude stdio shim. | +| New problem 8: generation semantics are incomplete for execution-plan-only changes | §2.4 is new. §2.4.1 defines the canonical document with eleven fields per tool, including `permission`, `dispatchKind`, `dispatchTarget`, `contextBindingPaths`, `argsIntoPath`, `staticBodyDigest`, and `timeoutMs`. §2.4.2 excludes rotating credentials and explains the include-what-changes-meaning rule. §2.4.3 defines the execution-plan-only case: the generation advances, parked authorizations fail closed, and the harness session is NOT reopened. §2.4.4 gives six tests. | +| Item 6 status: acknowledgement, generation coupling, transport-specific capability, Pi execution revocation, continuity verification | Unchanged from gate 1. §1, §2.1 to §2.3, §2.5, §3, §4.2, §5.2, §6.2. | + +Two things this rewrite makes explicit that gate 1 left implied: + +- The four bounding rules in §4.3.2 are now load-bearing, not defense in depth. If §2.3 or §4.2 + half 2 is dropped during implementation, the acknowledgement channel becomes security-critical + and this contract no longer holds. +- §2.4.3 splits the generation's two consumers. Authorizations need every behavior-bearing field. + Harness reconciliation needs only the model-visible subset. The router must ask both questions. + +Not resolved here, by design: + +- Gate 2 item 7, the slice plan, belongs to `plan.md`. §8 gives the order this contract needs. +- Whether Pi's ACP surface can report its active tool set, which would replace §4.3.3 with the + stronger §4.3.4 design. The spike found no such surface. It needs a live check, not another + static read. diff --git a/docs/design/agent-config-editing/contracts/change-set.md b/docs/design/agent-config-editing/contracts/change-set.md index 4065b3cf5f..b7a28a6f7b 100644 --- a/docs/design/agent-config-editing/contracts/change-set.md +++ b/docs/design/agent-config-editing/contracts/change-set.md @@ -54,17 +54,35 @@ The model-visible schema is `_COMMIT_REVISION_INPUT_SCHEMA` in | Field | Model-visible | Note | |---|---|---| | `workflow_revision.workflow_variant_id` | no | Bound from `$ctx.workflow.variant.id` and stripped. | -| `workflow_revision.base_revision_id` | yes | The runner defaults it only when absent. `commit-transaction.md` section 8. | +| `workflow_revision.base_revision_id` | yes | An ordered delta needs it. The model copies it from the `read_config` response. `read-config.md` section 10.1. | | `workflow_revision.message` | yes | | | `workflow_revision.delta` | yes | The `oneOf` of section 3. | -| `...operations[].value_from.type` | yes | `"workspace"`. | -| `...operations[].value_from.path` | yes | Relative to the import root. | -| `...operations[].value_from.on_unsupported` | yes | `"reject"` (default) or `"omit"`. Section 5.1. | -| `...operations[].value_from.allow_executable_files` | yes | Boolean, default `false`. Section 5.1. | +| `value_from.type` | yes | `"workspace"`. On `set`, `add_item`, and `replace_item`. | +| `value_from.path` | yes | Relative to the import root. A folder for the item verbs, one file for `set`. | +| `value_from.on_unsupported` | yes | `"reject"` (default) or `"omit"`. Folder source only. Section 5.1.3. | +| `value_from.on_executable` | yes | `"reject"` (default) or `"import"`. Folder source only. Section 5.1.3. | +| `value_from.persist_executable_capability` | yes | Boolean, default `false`. Folder source only. It needs `on_executable: "import"`. Section 5.1.3. | -The two `value_from` policy fields must appear here, or the model cannot set them and the -defaults become the only reachable behavior. The runner strips the whole `value_from` -object during resolution, so neither field ever reaches the API. +The union carries two source schemas, one per shape: + +| Operation member | `value_from` | Fields | +|---|---|---| +| `add_item`, `replace_item` | the folder source | `type`, `path`, and the three policy fields | +| `set` | the file source | `type` and `path` only | +| `merge`, `remove`, `edit_text`, `remove_item` | none | the member must not offer the field | + +Section 5.1 gives the reason for each row. Section 5.1.1 lists the three conditions a +`set` source must meet, and section 5.1.3 explains why the file source carries no policy +fields. + +The three policy fields must appear here, or the model cannot set them and the defaults +become the only reachable behavior. The runner strips the whole `value_from` object during +resolution, so no `value_from` field ever reaches the API. + +`allow_executable_files` is no longer a `value_from` field. It is now only the persisted +`SkillTemplate.allow_executable_files`, and an import sets it only through +`persist_executable_capability`. `workspace-import.md` section 5.2 owns the four-layer +split that this follows. Nothing else is model-visible. `data`, `flags`, `name`, `description`, `tags`, and `meta` stay off the model surface. `read-config.md` section 11 defines the second gate, the scope @@ -200,26 +218,88 @@ collides. ### 5.1 Value sources -| Operation | `value` | `value_from` | -|---|---|---| -| `set` | yes | yes | -| `merge` | yes | **no** | -| `remove` | no | no | -| `edit_text` | no | no | -| `add_item` | yes | yes | -| `replace_item` | yes | yes | -| `remove_item` | no | no | - -`merge` does not take `value_from`. A workspace source materializes a whole object, such -as a complete skill. A deep merge of a whole materialized object into an existing object -hides which fields survived. The result depends on the folder content, and the human who -approves the call cannot see it. `set` and `replace_item` state the intent clearly. +| Operation | `value` | `value_from` | Source shape | +|---|---|---|---| +| `set` | yes | yes, restricted | exactly one file. Section 5.1.1. | +| `merge` | yes | **no** | — | +| `remove` | no | no | — | +| `edit_text` | no | no | — | +| `add_item` | yes | yes | one folder, converted to an item. | +| `replace_item` | yes | yes | one folder, converted to an item. | +| `remove_item` | no | no | — | + +The rule follows the approval screen, not the engine. A human approves an import before the +runner reads the bytes. The human must therefore see a readable change, never a byte count +and a path. + +`merge` does not take `value_from` at all. A source materializes a whole object. A deep +merge of a whole materialized object into an existing object hides which fields survived. +The result depends on the folder content, and the human who approves the call cannot see +it. + +#### 5.1.1 `set` with `value_from`: three conditions, all required + +The team lead decided this on 4 August, in answer to gate 2, new problem 9. The oversized +instruction file is the founding use case of this project (#5554), so `set` must have a +path for it. + +`set` accepts `value_from` only when all three conditions hold. The runner refuses the +call before it reads any content if any one of them fails. + +**Condition 1: the source resolves to exactly one file.** Never a folder. A folder has no +single text to show, and the file-manifest presentation belongs to the item verbs. A source +path that names a directory is `source_invalid`. A source path that matches more than one +file is `source_invalid`. + +**Condition 2: the target's last segment is a string-typed field.** The value replaces one +long-text field, not a structure. Four target shapes are allowed: + +| Field | Target | +|---|---| +| the instructions | `["parameters","agent","instructions","agents_md"]` | +| a skill body | `[...,{"field":"skills","key":K},"body"]` | +| a skill file's content | `[...,{"field":"skills","key":K},{"field":"files","key":P},"content"]` | +| a code tool's script | `[...,{"field":"tools","key":N},"script"]` | + +The field must already exist and must already hold a string. Parent creation +(section 5.3) does not apply to a `set` that carries `value_from`: a missing field is +`target_not_found`, and a non-string field is `target_type_mismatch`. A field that does not +exist yet has no old text, so it has no honest diff. Use `add_item` for a new skill file. + +**Condition 3: the approval shows a unified diff of the old text against the new text.** +The card presents a readable change: the target field, the diff, the line counts, and the +digest of the exact bytes that will be committed. It must not present a byte count alone. +`workspace-import.md` section 8 owns the presentation; runner-spike adds the single-text-file +mode there. + +Two notes on the diff, for the runner to settle: + +- The old side comes from the configuration the runner holds for the current run. That + configuration can be behind the head. The base check catches the drift and answers 409 + (`commit-transaction.md` section 6), so the human never approves a diff that then commits + silently against a different base. +- If the runner cannot obtain the old text, it must show the complete new text and say that + no old text was available. It must never fall back to a byte count. + +#### 5.1.2 Folder into `set` stays disallowed + +A folder source into a `set` target is refused, and it stays refused. There is no honest +presentation for it. A folder carries many files, and a `set` target is one field. The card +would have to either flatten the folder into one value, which the human cannot review, or +list the files without showing what each one becomes, which is the byte-count-and-path +approval that condition 3 exists to prevent. The item verbs already carry folders, and they +carry them with an item identity the card can name. A value-bearing operation carries exactly one of `value` and `value_from`. Both is -`invalid_operation`. Neither is `invalid_operation`. +`invalid_operation`. Neither is `invalid_operation`. A `value_from` on `merge`, `remove`, +`edit_text`, or `remove_item` is `invalid_operation`, and the schema refuses it first. The engine refuses `value_from` with `source_invalid`. The runner must resolve it first. +#### 5.1.3 Two source schemas, one per source shape + +The folder source, on `add_item` and `replace_item`: + ```json { "type": "object", @@ -229,28 +309,76 @@ The engine refuses `value_from` with `source_invalid`. The runner must resolve i "type": { "const": "workspace" }, "path": { "type": "string", "minLength": 1 }, "on_unsupported": { "enum": ["reject", "omit"], "default": "reject" }, - "allow_executable_files": { "type": "boolean", "default": false } + "on_executable": { "enum": ["reject", "import"], "default": "reject" }, + "persist_executable_capability": { "type": "boolean", "default": false } + } +} +``` + +The file source, on `set`: + +```json +{ + "type": "object", + "additionalProperties": false, + "required": ["type", "path"], + "properties": { + "type": { "const": "workspace" }, + "path": { "type": "string", "minLength": 1 } } } ``` -`on_unsupported` and `allow_executable_files` are import-policy declarations. The -runner's import resolver consumes them. `workspace-import.md` section 4.2 defines -`on_unsupported` and its default, section 4.3 defines the `omit` opt-in, and section 5.2 -defines `allow_executable_files`. +The three folder-source policy fields, in one line each: + +| Field | Meaning | +|---|---| +| `on_unsupported` | `"reject"` (default) refuses a folder that holds an unsupported file. `"omit"` imports the rest and lists every omission. | +| `on_executable` | `"reject"` (default) refuses a folder that holds an executable file. `"import"` imports the folder and records the observed bits. | +| `persist_executable_capability` | `false` (default) commits `SkillTemplate.allow_executable_files` as false. `true` commits it as true. | + +**One constraint binds the last two: `persist_executable_capability: true` needs +`on_executable: "import"`.** The reverse is allowed. A caller may import the bits without +granting the runtime capability, which gives a faithful copy of the folder that still +cannot execute anything. A caller may not grant the runtime capability for bits it never +permitted itself to read. A violation is `invalid_operation`, and the runner refuses it +before any workspace read. + +The two fields are separate because they are two grants with two owners and two lifetimes. +`on_executable` is an import grant: the caller and the human approver own it, and it dies +with the operation. `persist_executable_capability` writes a stored capability that lives +for the life of the revision. `workspace-import.md` section 5.2 defines the four-layer +split this follows, and it shows the two as separate lines on the approval card. + +The file source carries no policy fields, because none of the three has a meaning for it: + +- `on_unsupported` chooses between refusing a folder and omitting some of its files. A + single-file source has nothing to omit. An unsupported single file always rejects, with + `source_unsupported_content`. +- `on_executable` grants an import the right to carry executable bits. A `set` writes text + into an existing string field. It creates no file entry, so it carries no bit. +- `persist_executable_capability` writes `SkillTemplate.allow_executable_files`. A `set` + never writes a skill template. It writes one long-text field inside an existing one. + +The three are import-policy declarations on the folder source. The runner's import +resolver consumes them. `workspace-import.md` section 4.2 defines `on_unsupported` and its +default, section 4.3 defines the `omit` opt-in, and section 5.2 defines `on_executable`, +`persist_executable_capability`, and the constraint between them. Three points fix their place: 1. **They sit on the source, not on the operation.** One commit can import two folders and - give each one a different answer. A field on the operation could not do that. + give each one a different answer. A field on the operation could not do that. With + `value_from` the runner generates the whole value, so the caller has no `value` object + to write `persist_executable_capability` into either. 2. **The engine never sees them.** The runner resolves `value_from` and then strips the - whole `value_from` object. It puts a plain inline `value` in place of it. So these two + whole `value_from` object. It puts a plain inline `value` in place of it. So these fields never reach the API, and the engine surface does not grow. The engine still refuses any `value_from` that survives, with `source_invalid`. 3. **The model must be able to write them.** This schema is model-facing. With `additionalProperties: false` and no such fields, the defaults would be the only - reachable behavior. A skill folder with one binary asset would then be permanently - uncommittable. + reachable behavior. A skill folder with one binary asset, or with one script, would + then be permanently uncommittable. This resolves the conflict `workspace-import.md` section 11 raises against this section. @@ -290,6 +418,8 @@ Replaces the target value exactly. `value: null` writes null; it does not remove engine does not overwrite it with `{}`. 5. Final validation stays mandatory. Parent creation is a convenience, not a licence to invent fields. The closed agent template rejects an invented path at validation. +6. Parent creation does not apply when the operation carries `value_from`. That form needs + an existing string target, so it has an old text to diff. Section 5.1.1. Example. With `harness: {"kind": "pi_agenta"}` in the base: @@ -587,10 +717,16 @@ see `commit-transaction.md`. | `invalid_delta` | Both forms, no form, or an unknown delta field. | no | | `invalid_operation` | A shape error. | no | | `final_validation_failed` | The finished tree is not a valid configuration. | yes | +| `non_embeddable_reference` | The result embeds a static workflow that may not be embedded. | yes | `final_validation_failed` carries an `issues` array, so the agent gets every schema problem at once. +`non_embeddable_reference` is wrapper-owned, not engine-owned. The commit wrapper raises +it from the existing `_reject_non_embeddable_workflow_embeds` check. It shares this +envelope so the agent learns one error vocabulary. `commit-transaction.md` section 4.1 +defines when it runs. + ## 11. Final validation The engine takes a `validate` callable. The callable receives the finished tree. It @@ -609,7 +745,9 @@ The prototype is `api/oss/src/core/workflows/change_set.py` in worktree | # | Change | Where | |---|---|---| | 1 | Return `ChangeSetResult`, not a bare dict. Compute `changed`. Collect warnings. | `apply_change_set`, `_finish` | -| 2 | Split `VALUE_BEARING`. `merge` accepts `value` only; the schema must not offer it `value_from`. | `VALUE_BEARING`, `_operation_value` | +| 2 | Split `VALUE_BEARING`. `set`, `add_item`, and `replace_item` accept `value_from`; `merge` accepts `value` only. The schema must offer the folder source on the item verbs, the file source on `set`, and nothing on `merge`. | `VALUE_BEARING`, `_operation_value` | +| 2b | `set` must not create parents when it carries `value_from`, and its target must already hold a string. Sections 5.1.1 and 5.3. | `_apply_operation` | +| 2c | The folder source carries three policy fields: `on_unsupported`, `on_executable`, and `persist_executable_capability`. `allow_executable_files` is not one of them. Add the constraint check, `persist_executable_capability: true` needs `on_executable: "import"`, as `invalid_operation`. The runner enforces it before any read; the schema states it. Section 5.1.3. | new pydantic source models | | 3 | Accept and dispatch `match_mode`. Add a matcher table with one entry, `exact`. | `_apply_operation`, `apply_text_edits` | | 4 | Count occurrences with overlap. Replace `str.count` and `str.index`. | `apply_text_edits` | | 5 | Create missing plain-string object parents in `set`, under the five rules of 5.3. | `_apply_operation` | @@ -632,3 +770,30 @@ the two that pin non-overlapping counting and the absence of parent creation. 3. **Full-data commits.** They branch-touch everything, so rule 2 applies to them. The playground saves this way. We must measure how many existing configurations would gain a warning before we make rule 2 stricter. +4. **The four allowed `set` targets** (section 5.1.1) are the long-text fields we know + today. A later schema can add another one. The list must live in one place, beside the + `item_key` table, so the SDK and the server never disagree about it. +5. **Product call 1, storage normalization.** Gate 2 marks it as blocking engine and + transaction work. It changes exact matching, the stored bytes, and canonical equality. + Answer it before the engine slice starts. +6. **Product call 2, unique-name enforcement.** Gate 2 marks it as blocking engine + validation. It decides which legacy configurations stay committable. Section 8 holds + the recommended rule. + +## 14. Gate 2 resolution + +Gate 2 marked item 1 RESOLVED. Two later points still touch this file. + +| Gate point | Answered in | +|---|---| +| New problem 9. The approval manifest cannot describe `set` plus `value_from` | Section 5.1.1 defines the constrained form the team lead arbitrated on 4 August: `set` takes `value_from` when the source is one file, the target is one of four known long-text fields, and the approval shows a unified diff. Section 5.1.2 records that a folder into `set` stays disallowed, because it has no honest presentation. `workspace-import.md` section 8 gains the single-text-file mode; runner-spike owns that edit. | +| New problem 6. The embed check must survive the transaction | Section 10 adds the `non_embeddable_reference` reason code and marks it wrapper-owned. `commit-transaction.md` section 4.1 owns the behavior. | +| New problem 10. `value_from` conflates import policy with a stored capability | Section 5.1.3 carries the four-layer split the team lead accepted on 4 August. The folder source now holds `on_unsupported`, `on_executable`, and `persist_executable_capability`. The old `allow_executable_files` field is gone from `value_from`; the persisted `SkillTemplate.allow_executable_files` is written only through `persist_executable_capability`. `workspace-import.md` section 5.2 owns the split. | +| Item 1, product calls 1 and 2 can still change the contract | Section 13 items 5 and 6 record both as blocking, with the section each one would change. | + +Supporting changes: section 2.1 tables and note, section 5.1 table, sections 5.1.1 to +5.1.3, section 10 reason table, section 12 rows 2, 2b, and 2c, and section 13 item 4. + +The founding use case is covered again. US-1 and #5554 are the oversized instruction file. +Section 5.1.1 gives it a path: one workspace file into +`["parameters","agent","instructions","agents_md"]`, approved as a unified diff. diff --git a/docs/design/agent-config-editing/contracts/commit-transaction.md b/docs/design/agent-config-editing/contracts/commit-transaction.md index 59c576c1d1..532187c503 100644 --- a/docs/design/agent-config-editing/contracts/commit-transaction.md +++ b/docs/design/agent-config-editing/contracts/commit-transaction.md @@ -59,12 +59,13 @@ BEGIN 2. SELECT the latest non-archived revision for that variant -> head 3. IF base_revision_id is present AND base_revision_id != head.id -> ROLLBACK, 409 revision_conflict (section 6) - 4. candidate = build(head.data) (section 4, pure, no I/O) + 4. candidate = build(head) (section 4, pure, no I/O) -> ChangeSetError: ROLLBACK, 422 (change-set.md section 10) + -> NonEmbeddableWorkflowReferenceError: ROLLBACK, 422 5. validate(candidate) (schema + unique names) -> issues: ROLLBACK, 422 final_validation_failed - 6. canonical_new = canonicalize(candidate) (section 5) - canonical_head = canonicalize(head.data) + 6. canonical_new = canonicalize(candidate) (section 5) + canonical_head = canonicalize(head) 7. IF canonical_new == canonical_head -> COMMIT (nothing was written), status = no_change, return head 8. INSERT the new revision row @@ -119,6 +120,7 @@ build(head): data = normalize_snippet_data(data) data = infer url from uri (when uri is set and url is not) data = merge interface schemas (retrieve_interface + infer_outputs_schema) + reject_non_embeddable_workflow_embeds(data) (MANDATORY, see 4.1) flags = infer_flags_from_data(...) return BuildOutcome(data=data, flags=flags, warnings=result.warnings) ``` @@ -126,6 +128,28 @@ build(head): A full-data commit skips the engine and starts from the supplied `data`. Every later step is identical, so both paths produce the same canonical form. +### 4.1 The non-embeddable-reference check stays + +`commit_workflow_revision` calls `_reject_non_embeddable_workflow_embeds` today +(`api/oss/src/core/workflows/service.py:1884`, definition at `:1353`). The checked +transaction must keep it. It is not optional, and it is not a legacy step. + +The check scans the finished configuration for `@ag.embed` references, and it refuses a +reference to a static workflow that may not be embedded. It raises +`NonEmbeddableWorkflowReferenceError`. Dropping it would let a change set write an embed +that the old path refused, so the ordered form would be weaker than the legacy form. + +Three properties make it fit inside the lock: + +1. It is synchronous, and it does no I/O. It reads the dumped configuration and the + in-memory static catalog. +2. It runs on the FINAL data, after every enrichment step. An embed can arrive through an + operation value, so the check must see the result, not the delta. +3. It maps to 422, like every other change-set refusal. The reason code is + `non_embeddable_reference`. Add it to the reason table of `change-set.md` section 10 as + a wrapper-owned code, and add `NonEmbeddableWorkflowReferenceError` to the + `suppress_exceptions(exclude=[...])` list of section 3.1. + ## 5. Canonicalization and the equality test The comparison happens on the form that would be stored, not on the engine's output. The @@ -133,18 +157,57 @@ enrichment of section 4 fills `url`, `schemas`, and `flags`. The stored head alr through the same pipeline. A comparison before enrichment reports a change when only the enrichment differs. +### 5.1 The comparison covers every persisted behavior-bearing field + +`data` alone is not enough. `flags` is computed by `infer_flags_from_data` and stored in +its own column (`api/oss/src/dbs/postgres/git/dao.py:1596`). Two revisions can hold equal +`data` and different `flags`, because the flag inference can change between deployments. +A comparison over `data` alone would then answer `no_change` for a commit that really does +change behavior, and the new flags would never reach the database. + +So the comparison covers a record, not a tree: + ```text -canonicalize(data) = data.model_dump(mode="json", exclude_none=True) - with every object key sorted, recursively +canonical(revision_like) = { + "data": json_dump(data, exclude_none=True), + "flags": json_dump(flags, exclude_none=True), +} +with every object key sorted, recursively, at every depth ``` -Three rules: +- `json_dump` is `model_dump(mode="json", exclude_none=True)`, the same call the insert + path uses. The comparison must use the persisted form, not the in-memory objects. +- The head side reads `head.data` and `head.flags` from the stored row. It does NOT + re-infer them. A re-inference would hide exactly the drift this rule exists to catch. +- Both sides sort object keys recursively, so key order never decides the answer. + +**Behavior-bearing means: the field changes what a run does.** `data` and `flags` do. +Section 5.2 lists what does not. + +### 5.2 What stays out of the comparison + +| Field | In the comparison | Why | +|---|---|---| +| `data` | yes | It is the configuration. | +| `flags` | yes | It routes and gates the run. Section 5.1. | +| `message` | no | Commit metadata. | +| `name`, `description` | no | Revision metadata. | +| `tags`, `meta` | no | Labels. They do not change a run. | +| `slug`, `id`, `version`, `author`, `date` | no | Identity, assigned at insert. | + +A commit that changes only a field in the second group is a no-change commit. It creates +no revision. A caller who wants to record a message with no configuration change must be +told that no revision was created; the `no_change` status and its warning do that. + +This is a deliberate call, and it has a cost: an agent cannot leave a note in the history +without changing something. Section 12 lists it as an open item. + +### 5.3 Three rules 1. **Validate before you compare.** An invalid change set must fail with 422, even when its result would equal the head. A caller who sends a bad operation must learn that. -2. **Compare the canonical persisted data only.** `message`, `name`, `description`, - `tags`, and `meta` are not part of the comparison. A commit that changes only the - message is a no-change commit. It creates no revision. +2. **Compare the canonical persisted record.** Section 5.1 defines it. `message`, `name`, + `description`, `tags`, and `meta` stay out. 3. **List order is data.** Two `tools` lists with the same entries in a different order are not equal. `add_item` appends, so a remove-then-add of the same entry moves it to the end and is a real change. @@ -156,11 +219,12 @@ The order is fixed. A stale base always wins. 1. The variant does not exist: 404. 2. `base_revision_id` is present and does not equal the head: **409**. 3. The change set fails: 422. -4. Final validation fails: 422. -5. The canonical result equals the head: **200 `no_change`**. -6. Otherwise: **200 `committed`**. +4. The non-embeddable-reference check fails: 422. Section 4.1. +5. Final validation fails: 422. +6. The canonical record equals the head record: **200 `no_change`**. Section 5.1. +7. Otherwise: **200 `committed`**. -Rule 2 beats rule 5 on purpose. A stale caller can produce a result that happens to equal +Rule 2 beats rule 6 on purpose. A stale caller can produce a result that happens to equal the new head. Answering `no_change` would tell that caller its base was current. It was not. The caller must re-read and decide again. @@ -241,7 +305,10 @@ model-supplied value (`sdks/python/agenta/sdk/agents/platform/op_catalog.py:91`) that hit a 409 would stay pinned to its stale run revision and could never retry inside the same run. The runner fills the field only when it is absent. -On a draft run there is no `$ctx.workflow.revision.id`. See `read-config.md` section 10. +On a draft run there is no `$ctx.workflow.revision.id`. The runner therefore fills nothing, +and no base check runs for a legacy draft-run commit. An ordered delta still needs the +value from the model. `read-config.md` section 10.1 states the single rule and names the +state source. ## 9. Events and cache @@ -270,6 +337,7 @@ The service layer must therefore translate: | Internal | HTTP | |---|---| | `ChangeSetError` | `HTTPException(422, detail=error.to_detail())` | +| `NonEmbeddableWorkflowReferenceError` | `HTTPException(422, reason `non_embeddable_reference`)` | | `RevisionConflict` | `HTTPException(409, detail={...})` | The translation lives at the service or router boundary, and `HTTPException` is already @@ -294,8 +362,20 @@ excluded from suppression. 7. **Enrichment parity.** A full-data commit and an equivalent delta commit produce the same canonical stored data. 8. **Suppression.** A forced `RevisionConflict` inside the DAO reaches the client as 409, - not as `count: 0`. + not as `count: 0`. Repeat it for `NonEmbeddableWorkflowReferenceError` and 422. 9. **Lock timeout.** A commit that waits longer than the statement timeout fails loudly. +10. **Flags decide equality.** A commit with identical `data` but different inferred + `flags` returns `committed`, and the stored row carries the new flags. Force the + difference by changing what `infer_flags_from_data` sees, not by writing flags + directly. Section 5.1. +11. **Flags are read, not re-inferred.** The head side of the comparison uses the stored + `flags` column. A test changes the inference rule, leaves `data` alone, and asserts + `committed`. +12. **The embed check survives.** A change set that writes an `@ag.embed` reference to a + non-embeddable static workflow returns 422 `non_embeddable_reference`, through both + the ordered form and the legacy form. Section 4.1. +13. **The embed check sees the result.** The embed arrives through an operation `value`, + not through the base. The check must still catch it. ## 12. Open items @@ -311,3 +391,27 @@ excluded from suppression. (`api/oss/src/dbs/postgres/git/dao.py:1668`). 4. **Statement timeout value.** Pick it with the team, and make it an env setting through `api/oss/src/utils/env.py`. +5. **A message-only commit creates nothing.** Section 5.2 keeps `message` out of the + comparison, so an agent cannot leave a note in the history without a real change. If + the team wants a note-only revision, that is a new decision, and it needs its own + status value. +6. **Product call 12, storing the authored operations.** The gate lists it as blocking + this contract. If we store the operations, the persisted record grows a field. Decide + before the wrapper lands, or accept a schema migration later. + +## 13. Gate 2 resolution + +Gate 2 marked item 2 PARTIAL, with two named gaps. New problem 6 restates both. + +| Gate point | Answered in | +|---|---| +| §4 omits `_reject_non_embeddable_workflow_embeds` | Section 4 pseudocode, and section 4.1 for the rules, the error code, and the placement after enrichment. | +| §5 claims flags are canonicalized, but compares only `data` | Section 5.1. The comparison covers `data` and `flags` as one record. Section 5.2 lists what stays out and why. | +| Canonical equality must cover every persisted behavior-bearing field | Section 5.1 defines "behavior-bearing". Section 5.2 gives the full field table. | +| The checked build must retain the embed check | Section 4.1, point 2: it runs on the FINAL data, so an embed that arrives through an operation value is caught. | + +Supporting changes: section 3 pseudocode step 4, section 6 precedence steps 4 and 6, +section 10 error translation, and tests 8 and 10 to 13. + +Still open from gate 2, and not in this contract's scope: item 7, the slice plan, and +item 8, the mixed-version rollout order and kill switch. Both live in `plan.md`. diff --git a/docs/design/agent-config-editing/contracts/execution-authorization.md b/docs/design/agent-config-editing/contracts/execution-authorization.md index 11d50d4252..c7ec94a281 100644 --- a/docs/design/agent-config-editing/contracts/execution-authorization.md +++ b/docs/design/agent-config-editing/contracts/execution-authorization.md @@ -70,15 +70,101 @@ memory only. ### 2.3 Canonical forms -`argsDigest` and `contentDigest` both need one canonical serialization. The runner already has -one. `canonicalJson` in `services/runner/src/responder.ts` sorts object keys and rejects any -value that is not plain JSON. The authorization store must reuse it. +`argsDigest` and `contentDigest` need an EXACT canonical serialization. The runner must build a +new serializer for this. It must not reuse `canonicalJson` from +`services/runner/src/responder.ts`. -The runner must fail closed when canonicalization fails. It must not fall back to a weaker key. -`ApprovedExecutionGrants.grant` in the same file already fails closed on an unkeyable call. The -authorization store must do the same, but it must also refuse to mint the record. A grant that -cannot be keyed is a silent no-op today. An authorization that cannot be keyed must be an error -the model sees. +#### 2.3.1 Why the existing serializer is unsafe here + +`canonicalJson` calls `normalizeJsonish` before it serializes. Read +`services/runner/src/responder.ts` line 124 and the function below it. `normalizeJsonish` parses +any string that looks like a JSON object or array, and replaces the string with the parsed +value. `parseJsonContainer` even tolerates up to three stray trailing closers. + +So these two argument sets produce the same digest: + +```json +{"value": "{\"x\":1}"} +{"value": {"x": 1}} +``` + +And so does this third one: + +```json +{"value": "{\"x\":1}}}"} +``` + +An ordered operation's `value` field holds arbitrary JSON. A skill body is a string. A file's +content is a string. Any of them can look like JSON. So an attacker can write an authorized +call whose arguments differ from the executed call, and both digest the same. That defeats the +whole binding. It is the same-identifier argument-substitution hole the record exists to close. + +#### 2.3.2 The normalization is correct for its own job + +Do not remove `normalizeJsonish` from `ApprovedExecutionGrants`. It exists for a real reason, +recorded in its own comment: a model copying object-valued arguments out of a flattened replay +transcript writes them back as a JSON string. The stored approval and the re-issued gate must +still meet at one key. That is a matching problem, and lenient matching is the right answer for +it. + +Authorization is a different problem. It is an exact binding. Lenient matching is the wrong +answer for it. + +So the runner keeps two serializers with two jobs. This must be stated in both call sites, or a +later reader will "simplify" them back into one. + +| Serializer | Job | Behavior | +|---|---|---| +| `canonicalJson` (existing) | Approval-key matching across a replay | Lenient. Parses JSON-looking strings. | +| `strictCanonicalJson` (new) | Authorization digests | Exact. Never parses a string. | + +#### 2.3.3 `strictCanonicalJson` + +The new serializer has these rules. + +1. **It never inspects the content of a string.** A string serializes as a JSON string literal, + always. No parsing, no trimming, no trailing-closer tolerance. +2. **It preserves JSON types exactly.** A string stays a string. A number stays a number. A + boolean stays a boolean. `null` stays `null`. It never coerces between them. +3. **It sorts object keys** by the code-unit order of their UTF-16 representation. This makes the + output independent of insertion order. +4. **It preserves array order.** An array is ordered data. +5. **It rejects, rather than encodes, every value JSON cannot represent exactly.** This covers + `undefined`, a function, a symbol, `NaN`, `Infinity`, `-Infinity`, a `BigInt`, and any object + with a prototype other than `Object.prototype` or `Array.prototype`. +6. **It rejects a cycle.** +7. **It encodes a number by its shortest round-trip form**, which is what `JSON.stringify` + already produces. `-0` serializes as `0`, matching JSON. +8. **It escapes a string exactly as `JSON.stringify` does**, and it additionally escapes every + lone surrogate as `\uXXXX`, so an unpaired surrogate cannot make two different strings encode + to the same bytes. +9. **It does not honour a `toJSON` method.** A `toJSON` hook would let a crafted object choose + its own digest input. +10. **It reads only own enumerable properties.** It never walks a prototype chain. + +The digest is SHA-256 over the UTF-8 encoding of the serializer's output. + +#### 2.3.4 Fail closed + +Rejection is an error, not a fallback. The runner must not degrade to a weaker key when the +strict serializer refuses a value. + +At mint time, a rejection refuses to mint the record. The model sees the error. This is stricter +than `ApprovedExecutionGrants.grant`, which silently does nothing on an unkeyable call. A silent +no-op is acceptable for a matching heuristic. It is not acceptable for an authorization. + +At verify time, a rejection fails the verification closed. + +#### 2.3.5 Test obligations for the serializer + +- The three example argument sets in section 2.3.1 must produce three different digests. +- A JSON-looking string never changes type. `{"a": "[]"}` and `{"a": []}` differ. +- Key order does not change the digest. Array order does. +- `1`, `"1"`, and `true` all differ. +- `undefined`, `NaN`, `Infinity`, a `BigInt`, a `Date`, a `Map`, and a cycle each raise an error. +- A lone surrogate and a valid pair produce different digests. +- An object carrying `toJSON` digests by its own properties, not by the hook's output. +- A property added to `Object.prototype` never enters the digest. ## 3. Lifecycle @@ -134,7 +220,99 @@ by a write. The runner then substitutes the frozen value into the call body. It replaces `value_from` with `value`. It never rereads the folder. -### 3.4 Discard +### 3.4 Multi-source commits + +One commit may carry up to eight `value_from` operations. Sections 3.1 to 3.3 describe one +record. This section defines how the runner handles a set of them. The rule is that the set +behaves as one unit. + +#### 3.4.1 Mint: check the policy before any read + +The runner must decide the permission verdict for the whole call BEFORE it reads any folder. + +The order is fixed: + +1. Parse the call. Collect every operation that carries a `value_from`. Record the operation + index of each. +2. Read the permission plan verdict for the call. A `deny` verdict stops here. The runner returns + the deny reason. **It performs no workspace read at all.** +3. Check the per-call and per-turn limits in section 6.2 against the collected count. A breach + stops here, again before any read. +4. Resolve the sources in operation order. Mint one record per source. + +Step 2 matters on its own. A denied call must not touch the filesystem. Reading a folder for a +call that will never run leaks the folder's existence and its content into runner memory, and it +spends the turn's byte budget. Worse, on Daytona it runs a process inside the sandbox for a call +the policy already refused. + +If any source fails to resolve, the whole mint fails. The runner discards every record it minted +for that call and releases their bytes. It returns the failing operation's index and its +structured error. A commit is one atomic change, so a partially resolvable commit has no useful +meaning. + +The records for one call share one `catalogGeneration`, read once at step 4. They share one +expiry, computed once. This stops a set from ageing apart. + +#### 3.4.2 Verify: all or nothing + +The runner verifies the complete set before it consumes any of it. + +1. Determine the required set: every operation index in the executed call that carries a + `value_from`. +2. Look up a record for each required index, keyed by `toolCallId` plus `operationIndex`. +3. Run every check in section 3.2 against every record. +4. Every record must pass. One failure fails the whole call. + +Two extra checks apply to the set, and neither is implied by the per-record checks: + +- **No missing member.** Every required index must have a record. A call carrying three + `value_from` operations with only two records fails closed. +- **No extra member.** Every record held for this `toolCallId` must correspond to a required + index in the executed call. A record with no matching operation means the executed call is not + the approved call. This catches an attacker who removes an operation from an approved + multi-operation commit to change what the commit does. + +The `argsDigest` check already covers both cases when it passes, because it binds the whole +argument document. These two checks exist so the runner reports the real reason rather than an +opaque digest mismatch, and so the set logic holds even if the argument shape later changes. + +#### 3.4.3 Consume: one atomic step + +The runner consumes the complete set in one synchronous step, with no `await` inside it. + +``` +verifyAll(requiredIndexes) // no mutation + -> consumeAll(requiredIndexes) // synchronous, no await + -> substituteAll(callBody) // synchronous + -> execute(callBody) // the first await +``` + +`consumeAll` removes every record from the store, in one pass. JavaScript runs it to completion +without interleaving, so no second execute record can consume a member in the middle. + +The runner must not verify-and-consume one record, then await a read for the next. Any `await` +between two consumes opens a window where a concurrent forged record consumes the rest of the +set and executes a different commit. + +If `consumeAll` finds any record already consumed, it must restore nothing and execute nothing. +It fails the whole call. A partially consumed set is a bug, not a recoverable state, because +verification passed moments earlier under the same synchronous turn. + +#### 3.4.4 Substitute: all frozen values, then execute + +The runner replaces `value_from` with `value` for every required index, using each record's +frozen value. It does this after the consume, on a copy of the call body. + +It executes once, with the fully substituted body. It never executes a partly substituted body, +and it never issues one API call per source. + +#### 3.4.5 Failure and cleanup + +Any failure in 3.4.2, 3.4.3, or 3.4.4 discards every record for that `toolCallId` and releases +every frozen value. The runner does not leave a surviving member for a retry. A retry must +re-mint the whole set, so the human re-approves the whole commit. + +### 3.5 Discard The runner discards a record on every one of these events. @@ -245,7 +423,7 @@ snapshot of a folder should not authorize an execution hours later. ### 6.4 Cleanup obligations -The runner must release frozen bytes on every path listed in section 3.4. The turn's `finally` +The runner must release frozen bytes on every path listed in section 3.5. The turn's `finally` block is the backstop. It must clear the whole store. A leaked entry is a memory leak on a long-lived parked session, so the backstop must not be optional. @@ -382,6 +560,25 @@ These tests gate the slice. They are a contract, not a suggestion. - Exceeding the per-turn source count or the aggregate byte budget produces a structured error and releases every partial allocation. +**Multi-source commits.** +- A denied permission verdict on a call carrying three sources performs ZERO workspace reads. + Assert on the reader, not only on the result. +- A commit with three sources mints three records, all sharing one `catalogGeneration` and one + expiry. +- One source failing to resolve discards the other two records and releases their bytes. +- A call missing one of its three records fails closed, and consumes none of the other two. +- A call carrying a record for an operation index the executed call does not have fails closed. +- Removing one operation from an approved three-operation commit fails closed. +- Two concurrent execute records for the same multi-source call produce exactly one execution. +- The executed API call carries all three substituted values in one request. +- A failure during substitution releases all three frozen values. + +**Strict serializer.** +- The three argument sets in section 2.3.1 produce three different digests. +- The full list in section 2.3.5 passes. +- An authorization minted with the strict serializer does not verify against a digest computed + with `canonicalJson`. This guards against a later refactor merging the two. + All of the above must run on both the local relay host and the Daytona relay host. The gate review is explicit that static inspection does not prove Daytona behavior. @@ -391,6 +588,23 @@ review is explicit that static inspection does not prove Daytona behavior. tool-call-id cache with this contract. - `decisions.md`, the runner-spike block. Replace the "frozen per tool-call id, with inline resolution at execution as the fallback" line. -- `decisions.md`, open product call 4. Record Mahmoud's answer on the forced gate. +- `decisions.md`, open product call 4. Record Mahmoud's answer on the forced gate. Gate 2 notes + that calls 4 and 8 are one decision, so merge them. - `plan.md`. Split slice 3 into source codec, authorization and freeze integration, and approval user interface, as must-fix item 7 requires. + +## 12. Gate 2 resolution + +| Gate 2 point | Where it is answered | +|---|---| +| New problem 1: `argsDigest` is not exact; `canonicalJson` parses JSON-looking strings | §2.3.1 states the collision with the three colliding examples. §2.3.2 explains why the lenient serializer stays for approval matching. §2.3.3 specifies `strictCanonicalJson` with ten rules. §2.3.4 makes rejection an error. §2.3.5 and §10 give the tests. | +| New problem 2: multi-source commits lack atomic verify-and-consume | §3.4 is new. §3.4.1 puts the permission verdict before any workspace read. §3.4.2 verifies the complete set with no-missing and no-extra checks. §3.4.3 consumes synchronously with no `await` between members. §3.4.4 substitutes every value and executes once. §3.4.5 defines cleanup. §10 adds thirteen tests. | +| Item 4 status: record, lifecycle, frozen store, expiry, cold resume | Unchanged from gate 1. §2, §3.1 to §3.3, §5, §6, §7. | + +Not resolved here, by design: + +- Gate 2 product calls 4 and 8 are one decision and remain open. §4 states the two candidate + behaviors and says which one this contract implements. +- Gate 2 new problem 9 concerns the approval manifest for `set` with `value_from`. It belongs to + `workspace-import.md` §8 and `change-set.md` §5.1. +- Gate 2 item 7, the slice plan, belongs to `plan.md`. diff --git a/docs/design/agent-config-editing/contracts/read-config.md b/docs/design/agent-config-editing/contracts/read-config.md index 11cbbf7fae..50529b3387 100644 --- a/docs/design/agent-config-editing/contracts/read-config.md +++ b/docs/design/agent-config-editing/contracts/read-config.md @@ -30,19 +30,52 @@ PlatformOp( method="POST", path="/api/workflows/revisions/read-config", input_schema=_READ_CONFIG_INPUT_SCHEMA, - context_bindings={"target.workflow_variant_id": "$ctx.workflow.variant.id"}, + context_bindings={ + "target.workflow_variant_id": "$ctx.workflow.variant.id", + "target.run_is_draft": "$ctx.workflow.is_draft", + "target.run_revision_id": "$ctx.workflow.revision.id", + }, read_only=True, timeout_ms=15000, ) ``` -The binding gives the self-target guarantee. The model cannot name another variant, +The first binding gives the self-target guarantee. The model cannot name another variant, because the field is stripped from the model-visible schema and filled server-side (`sdks/python/agenta/sdk/agents/platform/op_catalog.py:91`). +### 2.1 Three bindings, because the endpoint cannot compute the draft state + +Gate 2, new problem 5, is correct: a variant id alone does not tell the endpoint whether +the run is a draft. The variant is the same on a draft run and on a committed run. The +draft fact lives in the run context, in the runner, and it must be carried in. + +Two more bindings carry it. Both resolve today. No new runner mechanism is needed: + +- `resolveCtxToken` walks any dotted path against the run-context blob. A unit test + already asserts `resolveCtxToken(RUN_CONTEXT, "$ctx.workflow.is_draft")` + (`services/runner/tests/unit/tool-direct.test.ts:314`). +- `RunContextWorkflow` carries `is_draft` and `revision` + (`sdks/python/agenta/sdk/agents/dtos.py:485`). The SDK sets `is_draft = revision is None` + (`sdks/python/agenta/sdk/agents/tracing.py:166`). + +So the two new bindings behave like this: + +| Run | `run_is_draft` | `run_revision_id` | +|---|---|---| +| pinned to a committed revision | `false` | the revision id | +| a playground draft | `true` | absent, because `$ctx.workflow.revision.id` does not resolve | + +Both fields are server-bound. The catalog strips them from the model-visible schema, so +the model cannot claim it is not on a draft run. + +`run_revision_id` is not the answer to the read. The endpoint still answers from the +committed head. The field lets the response state one more useful fact: whether the run's +own revision is still the head. Section 4 carries it as `run_revision_is_head`. + The op needs a new endpoint. The existing retrieve endpoint returns a whole revision. It -cannot do partial reads, it cannot answer the draft question, and it returns fields the -model must not see. +cannot do partial reads, it cannot receive these bindings, and it returns fields the model +must not see. ## 3. The request @@ -57,6 +90,8 @@ model must not see. "additionalProperties": false, "properties": { "workflow_variant_id": {"type": "string"}, + "run_is_draft": {"type": "boolean"}, + "run_revision_id": {"type": "string"}, "path": {"$ref": "#/$defs/Target"} } }, @@ -72,6 +107,10 @@ name in an operation. An absent `path` means the whole readable configuration. +`workflow_variant_id`, `run_is_draft`, and `run_revision_id` are server-bound. Section 2.1 +explains them. They are stripped from the model-visible schema, so the model never writes +them. + Examples: | Ask | `path` | @@ -95,6 +134,7 @@ Examples: }, "base_revision_id": "019c...", "is_draft": false, + "run_revision_is_head": true, "path": ["parameters", "agent", "llm"], "value": {"model": "openai/gpt-5", "extras": {"reasoning_effort": "high"}}, "bytes": 74, @@ -103,11 +143,14 @@ Examples: ``` - `revision` says exactly which version answered. This is RFC requirement 3 on the tool. -- `base_revision_id` is the value the agent must copy into its next commit. It equals +- `base_revision_id` is the value the agent **must copy** into its next commit. It equals `revision.id`. It is a separate field because the agent must not have to guess which id - the commit wants. -- `is_draft` says whether the run targets a committed revision or an unsaved playground - draft. Section 10 explains what it means for the answer. + the commit wants. Section 10.1 makes this the single rule, on every kind of run. +- `is_draft` comes from the `$ctx.workflow.is_draft` binding of section 2.1. The endpoint + echoes it; it does not compute it. Section 10 explains what it means for the answer. +- `run_revision_is_head` compares the bound `run_revision_id` with the head the endpoint + just read. It is `null` on a draft run, because the run has no revision. It tells the + agent whether the configuration it is running is still the head. - `path` echoes the resolved target, so a truncated model context still knows what it got. - `value` is the raw value at that path. @@ -239,9 +282,38 @@ The response must say this, not only through a flag: } ``` -The runner must fill `base_revision_id` for a draft-run commit from the read, not from -`$ctx.workflow.revision.id`, because that context value is absent -(`commit-transaction.md` section 8). +### 10.1 Who fills `base_revision_id`: one rule + +Gate 2 found a contradiction between section 4 and this section. Section 4 told the model +to copy the value. This section told the runner to fill it "from the read", and it named +no state for that. The runner keeps no read result, so that rule could not be built. + +**The single rule: the model always supplies `base_revision_id` for an ordered delta. It +copies the value from the `read_config` response. The runner never fills it for an ordered +delta.** + +The state source is the model's own context. The value travels from the response of one +tool call into the arguments of the next tool call. That is the only place it lives, and +it needs no new runner state. + +The runner keeps exactly one defaulting behavior, and only for the legacy form: + +| Delta form | Run | Who fills `base_revision_id` | +|---|---|---| +| ordered | any | the model, from the read response. Missing is 422. | +| legacy | committed | the runner, from `$ctx.workflow.revision.id`, only when the model omitted it. | +| legacy | draft | nobody. The context value does not resolve, so no base check runs. | + +Three points close the rule: + +1. The default never overwrites a model value. It is not a `context_bindings` entry. + `commit-transaction.md` section 8 explains why: a bound value would pin an agent to its + stale run revision after a 409, and it could never retry inside the same run. +2. On a draft run an ordered delta is still safe, because the model carries the id it read. + A legacy draft-run delta keeps today's last-write-wins behavior, and the response + carries a warning that says so. +3. `read_config` is therefore a hard prerequisite for ordered commits. Gate 1 item 7 + already asks the plan to order the slices that way. Two consequences we accept for v1: @@ -269,27 +341,47 @@ exposes `workflow_variant_id`, `message`, and `delta` only for every commit that arrives through the `commit_revision` platform tool. It does not run for a human or an SDK caller on the normal API. -| Target root | Rule | +The policy is an allow-list, not a deny-list. It names what the agent may write. Everything +else is refused with `out_of_scope`. + +| Target prefix | Rule | |---|---| -| `parameters` | allowed | -| everything else | refused, `out_of_scope` | +| `parameters.agent` | allowed, minus the refused subtrees below | +| every other `parameters` subtree | refused in v1. Section 11.1.1. | +| every other root | refused | -Inside `parameters`, four subtrees are refused: +Inside `parameters.agent`, five subtrees are refused: | Path | Why | |---|---| -| `parameters.agent.sandbox.kind` | The sandbox provider is a security and cost boundary. | -| `parameters.agent.sandbox.permissions` | The security boundary the agent runs inside. | -| `parameters.agent.harness.permissions` | The allow / ask / deny rules that gate its own tools. | -| `parameters.agent.runner.permissions` | The runner-enforced execution policy. | +| `harness.kind` | It is an identity and rebuild boundary. Section 11.1.1. | +| `harness.permissions` | The allow / ask / deny rules that gate the agent's own tools. | +| `runner.permissions` | The runner-enforced execution policy. | +| `sandbox.kind` | The sandbox provider is a security and cost boundary. | +| `sandbox.permissions` | The security boundary the agent runs inside. | An agent that could widen its own permission lists could grant itself any tool. An agent that could switch its sandbox could leave the boundary a human chose. Both are privilege escalation, and both are silent. -`parameters.agent.harness.kind` stays writable. Changing the harness costs a rebuild, and -it is a normal authoring choice, not a security boundary. This is a product call; section -13 lists it. +#### 11.1.1 Two v1 defaults, both fail-closed + +Gate 2 says product calls 10 and 11 leave the security scope unfinished, and that the +contract allows more than the recommendation. Both calls stay open for Mahmoud. Until he +answers, the contract takes the fail-closed side: + +| Call | v1 default | Reason | +|---|---|---| +| 10. `harness.kind` | **not writable** | It selects the coding agent, and it forces a full rebuild. A wrong self-directed switch can leave an agent that cannot run, and only a human can undo it. | +| 11. `parameters` outside `agent` | **not writable** | A workflow revision can hold other subtrees, such as `prompt`. A builder agent has no reason to write them in v1. | + +The direction of the default matters, and it is not symmetric. Widening an allow-list +later is additive: no stored configuration breaks, and no caller has to change. Narrowing +it later is a breaking change: a playbook that worked stops working, and the failure looks +like a bug to the user. So the safe default is the narrow one, in both cases. + +An answer from Mahmoud replaces either row. It does not change any other part of this +contract. ### 11.2 Where it runs @@ -368,17 +460,35 @@ want to persist it, we do that on purpose, with a decision. ## 13. Open items -1. **`harness.kind` writability** (section 11.1). It is currently writable. Is a - self-directed harness switch acceptable? A wrong choice can make an agent unable to - run, and only a human can undo it. -2. **`parameters` beyond `agent`.** A workflow revision can hold other `parameters` - subtrees, such as `prompt`. Should a builder agent be able to write them? The current - policy allows it. -3. **Storing the authored operations for audit.** The RFC promises to store the diff with - the commit. This contract does not do it. The review lists it as missing. It needs its - own decision, because it adds a column or a meta field. +1. **Product call 10, `harness.kind` writability.** The v1 default is now "not writable" + (section 11.1.1). Mahmoud can widen it. Widening is additive. +2. **Product call 11, `parameters` beyond `agent`.** The v1 default is now "not writable" + (section 11.1.1). Mahmoud can widen it. Widening is additive. +3. **Product call 12, storing the authored operations for audit.** The RFC promises to + store the diff with the commit. This contract does not do it. It needs its own + decision, because it adds a column or a meta field. 4. **`max_bytes` default.** 65536 is a guess. Measure a real agent configuration before we fix it. 5. **Draft reads, later.** Section 10 accepts that a draft run reads the head. RFC Q3 Option C (the runner answers from memory) stays parked. If users find the caveat confusing, that option comes back. +6. **`run_revision_is_head` on a stale run.** The field tells an agent that its running + configuration is behind the head. This contract does not say what the agent should do + about it. A tool description line is probably enough. Decide during the slice. + +## 14. Gate 2 resolution + +Gate 2 marked item 3 PARTIAL. New problem 5 restates the first point. + +| Gate point | Answered in | +|---|---| +| The catalog binds only `workflow_variant_id`, so no draft flag reaches the endpoint | Section 2.1. Two more server-bound context bindings, `$ctx.workflow.is_draft` and `$ctx.workflow.revision.id`. Both resolve through today's `resolveCtxToken`; the unit test at `services/runner/tests/unit/tool-direct.test.ts:314` proves the first one. | +| The draft claim must be implemented or dropped | Implemented. Section 2.1 carries the flag in, section 4 echoes it, and section 10 states what it means. The endpoint never computes it. | +| §4 and §10 contradict each other about `base_revision_id` | Section 10.1. One rule: the model always copies it from the read response for an ordered delta. The state source is the model's own context, carried call to call. The runner defaults it only for a legacy delta on a committed run. | +| Calls 10 and 11 leave the security scope unfinished | Section 11.1.1. Both take the fail-closed default in v1, with the reason that widening an allow-list later is additive and narrowing it is a breaking change. Section 13 keeps both open for Mahmoud. | + +Supporting changes: section 3 request fields, section 4 response fields, section 11.1 +rewritten as an allow-list, and section 13 items 1 to 3 and 6. + +`commit-transaction.md` section 8 now points at section 10.1, so the two contracts state +one rule. diff --git a/docs/design/agent-config-editing/contracts/workspace-import.md b/docs/design/agent-config-editing/contracts/workspace-import.md index 6f81937f52..da94cdad1b 100644 --- a/docs/design/agent-config-editing/contracts/workspace-import.md +++ b/docs/design/agent-config-editing/contracts/workspace-import.md @@ -85,27 +85,83 @@ after the check and before the read. This contract closes the window as far as the platform allows. It states plainly where the window remains. -**Local runs.** The runner opens each file once, with `O_NOFOLLOW`, and derives everything from -that one open file handle. +**Local runs.** The gate 1 version of this contract said that `fs.opendir` plus a per-entry +`open(path, O_NOFOLLOW)` gave the practical equivalent of an `openat` walk. That claim was +wrong, and the correction matters. + +`O_NOFOLLOW` refuses a symbolic link at the **final component of the path only**. Every +intermediate directory in the path is still resolved normally, and a symbolic link there is +followed. So an attacker who replaces an intermediate directory between the walk and the open +redirects the open outside the import root, and `O_NOFOLLOW` does not fire. + +Concretely, the runner walks to `imports/pdf-tools/scripts/` and lists `extract.py`. It then +opens the path `imports/pdf-tools/scripts/extract.py` with `O_NOFOLLOW`. An attacker replaces +`scripts` with a symbolic link to `/home/user/.ssh` in between. The open resolves through the +link, reaches `/home/user/.ssh/extract.py`, and succeeds. The final component was not a link, so +the flag stays silent. + +The fix is a real file-descriptor-relative walk. The runner never rebuilds a full path string +and never re-resolves from the root. + +1. Open the import root once. Verify it with `fstat` on the handle. +2. For each directory level, open the child **relative to the parent's descriptor**, with the + no-follow and directory flags set. In Node this is `fs.opendir` on a handle plus `openat` + semantics through `fs.promises.open` with a `dir` handle where the runtime exposes it, or a + small native helper where it does not. +3. Open each file relative to its parent directory's descriptor, with `O_NOFOLLOW`. +4. `fstat` every handle. Read the type, the size, and the mode from the handle, never from a + path. +5. Read the content from the same handle. +6. Close each handle when its level completes. + +This removes the class. A descriptor names an inode, not a path. Replacing a directory in the +tree after the runner holds its descriptor does not move the descriptor. The attacker can only +change what a **new** path lookup would find, and the runner performs none. + +Two implementation notes for the plan. + +- Node's public API does not expose `openat` directly. `fs.promises.opendir` returns a `Dir` with + no usable descriptor for relative opens on every platform. So this needs either a narrow native + helper, or a documented fallback. It is real work, not a flag change. The plan must budget it. +- If the fallback is used, the contract's threat model changes. State it, do not hide it. See + section 3.4. + +**Daytona runs.** Section 6 defines the manifest. The window there is wider, and section 6.5 +states plainly that it is not closed. + +### 3.3 The threat model, stated once + +Two different attackers appear in this contract. They need different answers, and conflating +them is what produced the wrong claim above. + +| Attacker | Capability | Where it applies | +|---|---|---| +| **Confused agent** | Writes files through its own tools. Follows an injected instruction to import the wrong folder. Does not race the runner. | Both local and Daytona. | +| **Adversarial sandbox process** | Runs arbitrary code inside the sandbox. Races the runner's filesystem calls deliberately. | Daytona, and a local run whose harness executes untrusted code. | + +The designated import root in section 2 defends against the confused agent. It is the primary +control, and it works against both attackers. + +The descriptor walk in section 3.2 defends against the adversarial process, on the local path. -1. Open the entry with `O_NOFOLLOW`. A symbolic link then fails the open. -2. `fstat` the handle. Read the type, the size, and the mode from the handle, not from the path. -3. Read the content from the same handle. -4. Close the handle. +Nothing in this contract fully defends against the adversarial process on the Daytona path. +Section 6.5 says so. -Directory traversal uses `openat`-style relative descent where the runtime allows it. Node's -`fs.opendir` plus per-entry `O_NOFOLLOW` opens gives the practical equivalent. A symbolic link -inside the tree is refused rather than followed. This is a change from the prototype, which -followed confined links. +### 3.4 If the descriptor walk cannot be built -The residual window is the directory walk itself. A directory can be swapped between the walk -and the open. The `O_NOFOLLOW` open bounds the damage: the attacker cannot redirect a read to a -target outside the tree through a link, because links do not open at all. +If the plan decides the native helper is not worth its cost for v1, the local path falls back to +a path-based walk with `O_NOFOLLOW`. That is acceptable only with all three of these: -**Daytona runs.** Section 6 defines the manifest. The window there is wider and section 6.4 -states it. +1. The contract, the plan, and the code comment all state that the local path defends against the + confused agent and not against an adversarial process. +2. The fallback is not described as a TOCTOU defense anywhere. +3. `contentDigest` still binds what was read. An attacker who wins the race changes what the + human approves, and the human still sees the substituted content on the card before approving. -### 3.3 Symbolic links are refused, not followed +Point 3 is the real backstop in the fallback case, and it is weaker than it sounds: it works only +because a human reads the card. It is not a technical control. + +### 3.5 Symbolic links are refused, not followed The prototype followed a link whose target stayed inside the workspace. This contract refuses every symbolic link inside an import folder. @@ -194,48 +250,147 @@ success from becoming a server-side validation failure. ## 5. Executable policy -### 5.1 Never derive policy from mode bits +### 5.1 Two rules -The prototype set `allow_executable_files` to true when any file carried the owner-execute bit. -That converts a filesystem fact into a policy grant. The gate review calls this out as a -separate missing product call. This contract removes it. +1. A filesystem fact never becomes a permission grant. +2. One field never carries two grants with two owners. + +The prototype broke rule 1. The gate 1 contract fixed rule 1 and broke rule 2. Section 5.2 +fixes both. + +### 5.2 The four-layer split + +**Decided by the team lead on 4 August, in answer to gate 2, new problem 10.** The two-field +version below is accepted as written, with the constraint, the pre-read refusal, and the +two-line card. The conservative alternative was rejected: an imported skill that is silently +inert, with no visible reason, is a worse failure mode than an explicit two-line card. + +Gate 2 new problem 10 is correct. The gate 1 version of this contract used one field, +`allow_executable_files`, for two different jobs with two different owners and two different +lifetimes. + +| Job | Question it answers | Owner | Lifetime | +|---|---|---|---| +| **Import grant** | May this import carry files whose executable bit is set? | The caller, confirmed by the human on the approval card. | One import operation. | +| **Persisted capability** | When this stored skill materializes, may its marked files become executable? | The stored configuration. | The life of the revision. | + +Conflating them means one model-authored value silently does both. A caller who only wants the +import to succeed also grants a permanent runtime capability. The caller may not intend that, and +the approval card cannot show the difference, because there is only one field to show. + +The split has four distinct layers. Each has one owner. -### 5.2 The rule +| Layer | Field | Owner | Where it lives | +|---|---|---|---| +| 1. Data | `files[].executable` | The source file | The persisted skill value | +| 2. Import grant | `value_from.on_executable` | The caller, plus the human approver | The operation. Ephemeral. Never persisted. | +| 3. Persisted capability | `SkillTemplate.allow_executable_files` | The stored configuration | The revision | +| 4. Runtime policy | The materializer's `execPolicy` | The platform or the deployment | `services/runner/src/engines/skills.ts` | -`allow_executable_files` defaults to false. The import never sets it from the filesystem. +Layer 1 is a fact, not a permission. The runner records the observed bit whatever the policy +says. Layer 4 already exists and already defaults to `deny`. -The caller states the policy on the operation: +The two new fields sit on the FOLDER source, because both describe this one import. The file +source used by `set` carries neither. Section 5.5 explains why. ```json { "value_from": { "type": "workspace", "path": "downloaded-skills/pdf-tools", - "allow_executable_files": true + "on_unsupported": "reject", + "on_executable": "reject", + "persist_executable_capability": false } } ``` -The field defaults to false when absent. +- `on_executable` accepts `"reject"` (the default) or `"import"`. Under `"reject"`, an + executable file in the folder rejects the whole import with + `source_executable_not_permitted`. Under `"import"`, the import proceeds and records the bits. +- `persist_executable_capability` is a boolean, default false. It sets + `SkillTemplate.allow_executable_files` on the committed value. + +One constraint binds them: **`persist_executable_capability: true` requires +`on_executable: "import"`.** The reverse is allowed. A caller may import the bits without +granting the runtime capability, which produces a faithful copy of the folder that still cannot +execute anything until someone grants layer 3 deliberately. A caller may not grant the runtime +capability for bits it never permitted itself to read. + +Violating the constraint is `invalid_operation`, refused before any workspace read. + +The approval card shows the two as separate lines, because they are separate grants: + +``` +Executable files: 3 imported (on_executable: import) +Runtime execution: NOT granted (persist_executable_capability: false) +``` + +**Why both fields sit on `value_from` rather than one moving to the operation.** The persisted +capability is a property of the skill, so at first sight it belongs on the operation's `value`. +But with `value_from` the runner generates the whole value, so the caller has no `value` object +to write it into. Putting it on the operation instead would make one operation carry a field that +only applies when a sibling field is present, which is worse. Keeping both on `value_from` keeps +the import declaration self-contained and keeps the constraint checkable in one place. + +**The rejected alternative, recorded.** One option was to drop +`persist_executable_capability`, always commit `allow_executable_files: false` on import, and +require a separate explicit operation to grant the runtime capability afterwards. It needs no +constraint, so it is simpler. The team lead rejected it on 4 August: an imported skill whose +scripts do not run, with nothing on screen to say why, is a worse failure mode than one extra +line on the approval card. Do not reintroduce it as a simplification. + +### 5.3 What the runner never does + +The runner never sets any of the four layers from the filesystem. + +The prototype set `allow_executable_files` to true when any file carried the owner-execute bit. +That converts a filesystem fact into a policy grant, and it is exactly what layer 1 versus layer +3 exists to prevent. It is removed. + +The import also does not silently clear a bit. A silent clear would produce a skill whose scripts +do not run, and the user would learn this much later. An executable file the caller did not +permit rejects the import instead. -### 5.3 How the mode bit is treated +### 5.4 How the mode bit is treated -The runner still reads each file's owner-execute bit. It uses it for two things. +The runner reads each file's owner-execute bit. It uses it for three things. -1. It sets `files[].executable` to the observed bit. This preserves the author's intent inside - the skill package. +1. It sets `files[].executable` to the observed bit. This is layer 1. 2. It reports every executable file in the manifest and on the approval card. +3. It compares the set against `on_executable` and rejects the import when the grant is absent. + +At run time an executable file needs three independent yes answers: `on_executable: "import"` at +the moment of import, the stored `allow_executable_files`, and the sandbox execution policy in +`resolveSkillDirs`. That is the intended depth, and each answer has a different owner. + +### 5.5 Two source shapes, two schemas + +`change-set.md` section 5.1.3 defines two source schemas. The split matters here, because it +decides which policy fields the import resolver reads. + +| Source shape | Used by | Carries | +|---|---|---| +| **Folder** | `add_item`, `replace_item` | `type`, `path`, `on_unsupported`, `on_executable`, `persist_executable_capability` | +| **File** | `set` | `type` and `path` only | + +The file source carries no policy field, because neither one has a meaning for it. -When `allow_executable_files` is false and the folder holds an executable file, the import -**rejects**. It returns `source_executable_not_permitted` and names the files. +- `on_unsupported` chooses between refusing a folder and omitting some of its files. A + single-file source has nothing to omit. An unsupported single file always rejects, with + `source_unsupported_content`. +- `on_executable` and `persist_executable_capability` govern a stored skill file's executable + bit. A `set` writes text into an existing string field. It never creates a file entry, and it + never changes an existing entry's `executable` flag. So there is nothing for either grant to + govern. -The import does not silently clear the bit. A silent clear would produce a skill whose scripts -do not run, and the user would learn this much later. +A policy field on a file source is `invalid_operation`. The schema refuses it first, because +both source schemas set `additionalProperties: false`. -The materializer's own policy still applies at run time. `resolveSkillDirs` in -`services/runner/src/engines/skills.ts` defaults to `deny`. So an executable file needs three -independent yes answers: the caller's `allow_executable_files`, the skill's stored -`allow_executable_files`, and the sandbox execution policy. That is the intended depth. +The import resolver therefore has two entry points, not one with a mode flag. The folder path +produces a `SkillTemplate` value and a file manifest. The file path produces one string and its +digest. They share the confinement rules in section 3, the caps in section 4.4, and the Daytona +reader in section 6. They share nothing else. ## 6. The Daytona reader @@ -271,45 +426,103 @@ Three framing rules make this safe. newline, a tab, a quote, or a backslash. It may not hold a NUL. So NUL is the only safe separator. The prototype's tab-and-newline framing was not safe. 2. **`%y` not `%Y`.** `%y` reports the type of the entry itself. A symbolic link reports `l`. - The runner then refuses it under section 3.3. `%Y` would follow the link and hide it. + The runner then refuses it under section 3.5. `%Y` would follow the link and hide it. 3. **`-maxdepth 8`** bounds the walk inside the command, so a deep or cyclic tree cannot make the command run long. -The runner runs a second command to resolve the root itself: +The runner runs two more commands to resolve paths. Both matter, and the gate 1 version got the +comparison wrong. ``` -realpath -- +realpath -- # -> R +realpath -- # -> F ``` -The result must equal the resolved workspace import root. This catches a symbolic link at the -root. +`` is the folder the caller named, which is `` joined with +`value_from.path`. Section 2.1 requires the caller to name a folder BELOW the import root, so `F` +and `R` are almost never equal. + +**The test is descendant, not equality.** `F` passes when `F` equals `R`, or when `F` starts with +`R` plus a path separator. The gate 1 text required `F` to equal `R`, which would have rejected +every valid import. That was a straight error. + +The runner compares the resolved strings after it normalizes each to a single trailing form. It +must not compare unresolved paths, because that is what the symbolic-link check exists to defeat. + +The same descendant test applies to every entry in the manifest. `find` prints `%P`, the path +relative to the folder it walked, so an entry cannot escape through its printed name. But a +directory in the tree may still be a link, and rule 2 above makes `find` report it as `l`. The +runner refuses it. -### 6.3 Reading the content +### 6.3 What the manifest does NOT establish + +`find` resolves paths through the sandbox's own view of the filesystem, at the moment it runs. +It is a snapshot of names, not a set of handles. The runner cannot hold a descriptor across the +daemon interface, so it cannot repeat the local descriptor walk here. + +So the Daytona path establishes: + +- what entries existed when `find` ran; +- their type, mode, and size at that moment; +- that the folder resolved under the import root at that moment. + +It does not establish that any of those facts are still true when `readFsFile` runs. + +### 6.4 Reading the content The runner then reads each accepted file with `readFsFile`. It reads only files the manifest listed as regular, in-cap, and non-link. The read count equals the accepted file count. It does not equal the entry count. -### 6.4 The residual window, stated plainly +### 6.5 The residual window, stated plainly + +**The two-pass manifest does not stop an adversarial sandbox process. It must not be described as +a snapshot.** + +The manifest and the reads are separate daemon calls. The daemon interface offers no atomic +open-and-read, and it hands the runner no descriptor to hold. So the runner has no way to bind a +read to the inode the manifest saw. + +The gate 1 version listed a second manifest pass as a bound. It is not one, for a concrete +reason. An attacker who controls a process inside the sandbox can: + +1. wait for the first `find` to finish; +2. replace a file with a symbolic link, or with different content of the **same size**; +3. let the runner's `readFsFile` return the substituted bytes; +4. restore the original before the second `find` runs. + +Both passes then agree. The size check agrees. The mode check agrees. The runner commits content +no human approved, and every check it performed passed. + +The size check and the second pass do have value. They catch a **non-adversarial** change: an +agent still writing to the folder, a background process, a partially written download. That is +worth keeping. It is a consistency check, not a security control, and this contract now calls it +one. -The manifest and the reads are separate daemon calls. A process inside the sandbox can change a -file between the two. The daemon interface offers no atomic open-and-read, so this window cannot -be closed from the runner. +What actually bounds the Daytona path: -Three things bound it. +1. **The import root.** An attacker must first place or modify content under `imports/`. The + run's own permission policy governs that write. This is the primary control, and it holds + against the confused agent. +2. **The human on the approval card.** The card shows the bytes the runner actually read. A + substitution changes what the human sees before they approve it. +3. **`contentDigest`.** What the human approved is what the API receives. A later change on disk + cannot alter the committed value. -1. The runner verifies each read against the manifest's recorded size. A size change rejects the - import with `source_changed_during_read`. -2. The runner re-runs the manifest command after the reads finish. Every accepted entry's type, - mode, and size must be unchanged. Any difference rejects the import. -3. The window is inside the sandbox's own trust boundary. An attacker who can write these files - can already write to `imports/`, and the run's permission policy governs that write. +None of the three stops a well-timed swap by a process that already runs arbitrary code inside +the sandbox. Against that attacker, on Daytona, this contract does not claim protection. -This is weaker than the local `O_NOFOLLOW` path. The plan must say so. It must not claim the two -paths give the same guarantee. +That is an honest position, and it is defensible: an attacker who already runs arbitrary code in +the sandbox can also write whatever it wants directly into `imports/` and let the import read it +legitimately. The race adds little to what that attacker can already do. What the race does add +is the ability to defeat the human's review, and the plan must record that as an accepted risk +rather than a solved problem. -### 6.5 Timeouts, cancellation, and memory +The plan and the code comment must both say: the Daytona import path is weaker than the local +descriptor walk, and it is not a TOCTOU defense. + +### 6.6 Timeouts, cancellation, and memory | Control | Value | |---|---| @@ -359,16 +572,32 @@ approval user interface can prove which manifest the human saw. It is separate f ## 8. The approval manifest and the card -### 8.1 The manifest +### 8.0 Two presentation modes + +The import has two source shapes, so the card has two modes. Section 5.5 defines the split. + +| Mode | Source | Operation | The card shows | +|---|---|---|---| +| **Item mode** | Folder | `add_item`, `replace_item` | A named item, a file manifest, and the body. Sections 8.1 to 8.3. | +| **Single-text mode** | One file | `set` | A unified diff of one string field. Section 8.4. | + +The runner selects the mode from the source shape, never from a caller-supplied flag. A folder +always renders the item mode. A file always renders the single-text mode. + +One rule holds in both modes, and it is the reason both exist: **the card never shows only a byte +count and a path.** A human approves a readable change, or the runner does not ask. + +### 8.1 The manifest, item mode The manifest is the structured record the approval card renders. The runner computes it once, at mint time. ``` { + mode: "item", sourcePath, itemName, - operation, // add_item | replace_item | set + operation, // add_item | replace_item intent, // "add" | "replace" totalBytes, fileCount, @@ -387,7 +616,7 @@ mint time. Every file carries its own digest. A user who wants to verify one file can do so without the whole content. -### 8.2 What the card shows +### 8.2 What the card shows, item mode The card shows, in this order: @@ -401,7 +630,7 @@ The card shows, in this order: 7. The file list, with sizes. 8. The totals and `contentDigest`. -### 8.3 Truncation rules +### 8.3 Truncation rules, item mode Long content must not be dropped silently and must not flood the card. @@ -421,16 +650,107 @@ The user interface must offer a way to see the full body and any single file's c demand. The runner serves that from the frozen value, so what the user reads is what will commit. +### 8.4 Single-text mode: `set` from one file + +`change-set.md` section 5.1.1 allows `value_from` on `set` under three conditions. The third one +is a presentation condition, and this section owns it. The founding use case of the project is +an oversized instruction file, so this path must exist and it must read well. + +#### 8.4.1 The manifest, single-text mode + +``` +{ + mode: "single_text", + sourcePath, + targetField, // a readable name, e.g. "instructions" or "skill pdf-tools / body" + target, // the structured target from the operation + newBytes, + newLines, + newDigest, + oldAvailable, // boolean + oldBytes, // present only when oldAvailable + oldLines, // present only when oldAvailable + oldDigest, // present only when oldAvailable + diff, // present only when oldAvailable + addedLines, // present only when oldAvailable + removedLines, // present only when oldAvailable + contentDigest, // equals newDigest in this mode + catalogGeneration +} +``` + +There is no `itemName`, no `files`, no `omitted`, and no executable section. A `set` writes one +string. None of those concepts applies. + +#### 8.4.2 What the card shows, with old text available + +1. The target field, by its readable name. "Replace the instructions" or "Replace the body of + skill `pdf-tools`". +2. The source path. +3. A **unified diff** of the old text against the new text. +4. The changed-line counts, as added and removed. +5. The old and new sizes, in bytes and lines. +6. `contentDigest`. + +The diff is the substance of the card. Items 4 to 6 support it. They never replace it. + +The old side comes from the configuration the runner holds for the current run. That +configuration can be behind the head. This is safe, because the base check answers 409 on drift +(`commit-transaction.md` section 6), so the human never approves a diff that then commits +silently against a different base. The card does not need to warn about this. + +#### 8.4.3 What the card shows, with old text unavailable + +The runner may not hold the old text. The field may sit outside the configuration the run +carries, or the run may carry no configuration at all. + +In that case the card shows the **complete new text**, and it says plainly why: + +``` +No previous text was available, so this shows the complete new content. +``` + +Two rules bind this case. + +- **The new text is shown in full.** It is not truncated to a preview. The human has no diff to + read, so the full text is the only thing that makes the change reviewable. +- **The card never falls back to a byte count and a path.** That is the failure mode condition 3 + exists to prevent, and it is the one this mode must never reach. + +If the complete new text exceeds the size the interface can render, the interface scrolls it. It +does not summarize it. The 200 000-byte per-file cap in section 4.4 bounds the worst case. + +#### 8.4.4 Truncation rules, single-text mode + +| Element | Rule | +|---|---| +| Diff, old text available | Capped at 400 lines, matching the item mode's replace rule. Beyond that, show the first 400 diff lines, then the changed-line counts and both digests. | +| New text, old text unavailable | **Never truncated.** See section 8.4.3. | +| Line counts and digests | Never truncated. | + +The 400-line diff cap is a display cap only. The card must state that `contentDigest` covers the +full new text, exactly as section 8.3 requires for the item mode. The interface must offer the +full diff and the full new text on demand, served from the frozen value. + +#### 8.4.5 What this mode does not do + +- It does not read policy fields. The file source carries `type` and `path` only. See section + 5.5. +- It does not create a field. `change-set.md` section 5.1.1 requires the target to exist and to + hold a string already, because a field with no old text has no honest diff. +- It does not accept a folder. A directory source, or a source matching more than one file, is + `source_invalid` and the runner refuses it before it reads any content. + ## 9. Error codes | Code | Meaning | |---|---| | `source_not_found` | The path does not exist under the import root. | | `source_escapes_workspace` | The path resolves outside the import root. | -| `source_invalid` | No `SKILL.md`, bad frontmatter, unsafe name, or a malformed path. | +| `source_invalid` | No `SKILL.md`, bad frontmatter, unsafe name, a malformed path, or a folder / multi-file source on `set`. | | `source_too_large` | The aggregate cap, the `SKILL.md` cap, or the manifest cap was passed. | | `source_unsupported_content` | One or more unsupported files, under `on_unsupported: reject`. | -| `source_executable_not_permitted` | An executable file with `allow_executable_files` false. | +| `source_executable_not_permitted` | An executable file under `on_executable: reject`. | | `source_changed_during_read` | The folder changed between the manifest and the read. | | `source_read_failed` | A daemon or filesystem error. | | `source_timeout` | The import passed its deadline. | @@ -445,13 +765,29 @@ Every code carries the offending paths, up to 20, and a count of the rest. - A symbolic link at the folder root is refused. - A symbolic link inside the folder is refused, even when its target stays inside the workspace. - A path outside `imports/` but inside the workspace is refused. - -**TOCTOU.** -- Local: replace a file with a symbolic link between the walk and the open. The open must fail. -- Daytona: change a file's size between the manifest and the read. The import must reject with +- A valid folder BELOW `imports/` is accepted. This is the descendant test in section 6.2, and + the gate 1 equality test would have failed it. Add it as a regression guard. +- `imports/` itself is refused, per section 2.3. + +**TOCTOU, local.** +- Replace the FINAL component with a symbolic link between the walk and the open. The open must + fail. +- Replace an INTERMEDIATE DIRECTORY with a symbolic link pointing outside the import root, + between the walk and the open. The descriptor walk must still read the original file. This is + the case the gate 1 contract claimed to stop and did not. It must fail against a path-based + walk and pass against the descriptor walk. +- If the fallback in section 3.4 ships, the intermediate-directory test is marked as a KNOWN + FAILURE with a reference to section 3.4. It must not be deleted, and it must not be marked + passing. + +**TOCTOU, Daytona.** +- Change a file's size between the manifest and the read. The import must reject with `source_changed_during_read`. -- Daytona: change a file's mode between the manifest and the verification pass. The import must - reject. +- Change a file's mode between the manifest and the verification pass. The import must reject. +- Swap content of the SAME SIZE in for the read and swap it back before the verification pass. + The import SUCCEEDS and commits the substituted bytes. This test asserts the documented + weakness in section 6.5. It exists so nobody later believes the two-pass manifest is a + snapshot. Name it accordingly. **Unsupported content.** - A binary file rejects the whole import by default. @@ -461,10 +797,31 @@ Every code carries the offending paths, up to 20, and a count of the rest. - An aggregate-cap breach rejects under both `reject` and `omit`. **Executable policy.** -- An executable file with no `allow_executable_files` rejects. -- The same folder with `allow_executable_files: true` imports, and `files[].executable` is true. -- `allow_executable_files` is never true when the caller did not ask for it, whatever the mode - bits say. +- An executable file under the default `on_executable: "reject"` rejects the import. +- The same folder with `on_executable: "import"` imports, and `files[].executable` is true. +- `on_executable: "import"` alone leaves `SkillTemplate.allow_executable_files` FALSE. +- `persist_executable_capability: true` sets it true, and only then. +- `persist_executable_capability: true` with `on_executable: "reject"` is `invalid_operation`, + and performs no workspace read. +- `SkillTemplate.allow_executable_files` is never true because of a mode bit. Import a folder + full of executable files with `persist_executable_capability` absent; the stored value is + false. +- The approval card shows the import grant and the runtime grant as two separate lines. + +**Single-text mode (`set` from one file).** +- A folder source on `set` is `source_invalid`, refused before any content read. +- A source matching more than one file is `source_invalid`. +- A policy field on a file source is refused by the schema. +- With old text available, the card renders a unified diff plus changed-line counts. +- With old text UNAVAILABLE, the card renders the COMPLETE new text and states why. Assert on + the full text, not on its presence. +- The card never renders only a byte count and a path. Assert this for both the available and + the unavailable case. +- A diff longer than 400 lines truncates the DIFF and still states that the digest covers the + full text. +- New text longer than 400 lines with no old text is NOT truncated. +- A target that does not exist is `target_not_found`. A non-string target is + `target_type_mismatch`. Neither reads any content. **Digest.** - Two imports of an unchanged folder give the same `contentDigest`. @@ -503,13 +860,20 @@ record. } ``` -This contract adds two fields to that object: `on_unsupported` and `allow_executable_files`. -Under `additionalProperties: false` the model could not write them, so the two contracts -conflict as written. +This contract adds three fields to the FOLDER source: `on_unsupported`, `on_executable`, and +`persist_executable_capability`. Under `additionalProperties: false` the model could not write +them, so the two contracts conflicted as written. + +`change-set.md` section 5.1.3 has since split the source into two schemas, which resolves the +shape of the conflict. The field names there now match this section: the folder source +carries `on_unsupported`, `on_executable`, and `persist_executable_capability` beside `type` +and `path`, and the file source carries `type` and `path` only. (An earlier draft of this +paragraph flagged a stale name; the two files were edited concurrently and the flag was +outdated on arrival. Verified in the current text of both files.) The conflict must be resolved before either slice starts. The resolution this contract proposes: -**Widen the `value_from` schema in `change-set.md` to hold the two policy fields.** +**Widen the `value_from` schema in `change-set.md` to hold the three policy fields.** ```json { @@ -520,11 +884,18 @@ The conflict must be resolved before either slice starts. The resolution this co "type": { "const": "workspace" }, "path": { "type": "string", "minLength": 1 }, "on_unsupported": { "enum": ["reject", "omit"], "default": "reject" }, - "allow_executable_files": { "type": "boolean", "default": false } + "on_executable": { "enum": ["reject", "import"], "default": "reject" }, + "persist_executable_capability": { "type": "boolean", "default": false } } } ``` +The constraint in section 5.2 — `persist_executable_capability: true` requires +`on_executable: "import"` — is not expressible cleanly in this schema. Enforce it in the +runner's own validation and return `invalid_operation`. Do not encode it as a JSON Schema +dependency; a catalog schema the model reads should stay simple, and the runner refuses the +combination before any workspace read either way. + Three reasons this is the right side to change. 1. Both fields are decisions the caller makes about one import. They belong with the import @@ -554,3 +925,18 @@ model-visible catalog schema and repeats that the runner strips the object. | `decisions.md` open call 6 | The reach is the designated `imports/` root, not the whole workspace. | | `spikes/runner-spike.md`, "Codec gaps" | `allow_executable_files` is no longer derived. Binary and oversized files no longer drop silently. Symbolic links are no longer followed. | | `plan.md` | Add the `imports/` root creation to the workspace slice. Add the Daytona reader as its own unit of work. | + +## 13. Gate 2 resolution + +| Gate 2 point | Where it is answered | +|---|---| +| New problem 3: local `O_NOFOLLOW` does not protect a replaced intermediate directory | §3.2 retracts the gate 1 claim, shows the concrete attack, and specifies a descriptor-relative walk. §3.3 names the two attackers. §3.4 defines the honest fallback if the native helper is not built. §10 adds the intermediate-directory test, including its known-failure form. | +| New problem 4: Daytona root check is equality, and the two-pass manifest is still raceable | §6.2 replaces equality with a descendant test and explains why the gate 1 text would have rejected every valid import. §6.3 states what the manifest does not establish. §6.5 retracts the two-pass bound, walks the same-size swap attack step by step, demotes the passes to a consistency check, and states what actually bounds the path. §10 adds a test that ASSERTS the weakness. | +| New problem 10: `allow_executable_files` conflates import grant and persisted capability | **Decided 4 August.** §5.2 splits the concern into four layers with one owner each: `on_executable` is the import grant, `persist_executable_capability` is the persisted capability, and the second requires the first. Violation is refused before any workspace read. The card shows two separate lines. §5.3 and §5.4 follow. §5.5 keeps both fields off the file source. §11 widens the folder schema. §10 replaces the executable tests. The conservative alternative is recorded as rejected, with its reason. | +| New problem 9: the approval manifest cannot describe every allowed operation | **Resolved jointly.** `change-set.md` §5.1.1 allows `value_from` on `set` under three conditions and §5.1.3 splits the source schema. This contract owns condition 3. §8.0 defines the two presentation modes and the rule they share: the card never shows only a byte count and a path. §8.4 defines the single-text mode, including the diff, the counts, the digests, and the no-old-text case that shows the COMPLETE new text and says why. §5.5 records the source-schema split. §10 adds ten single-text tests. | +| Item 5 status: default policy, limits, manifest, Daytona framing | Unchanged from gate 1. §2, §4, §6.2, §6.6, §7, §8.1 to §8.3. | + +Not resolved here, by design: + +- Gate 2 item 7, the slice plan, belongs to `plan.md`. §2.3, §3.2, and §6 each name work the + plan must budget. diff --git a/docs/design/agent-config-editing/decisions.md b/docs/design/agent-config-editing/decisions.md index 763deb726f..41a2254b8a 100644 --- a/docs/design/agent-config-editing/decisions.md +++ b/docs/design/agent-config-editing/decisions.md @@ -67,6 +67,25 @@ reasoning is in `spikes/engine-spike.md` (D1-D33, O1-O12) and `spikes/runner-spi (Empty. The open calls below move here once answered.) +## Arbitrations after gate 2 (team lead, 4 August) + +- **Text-file import into a text field.** `value_from` on `set` is allowed under three + required conditions: single file source, string-typed target from the four known + long-text fields, unified diff at approval. Folder-into-`set` stays disallowed. This + restores the founding use case (an oversized instruction file, issue #5554). + change-set.md §5.1.1-§5.1.2; workspace-import.md §8. +- **Executable permission, four layers, one owner each.** The file's `executable` bit + is data. `on_executable` on the folder source is the ephemeral import grant, default + reject. `persist_executable_capability` writes the stored skill capability, default + false, and requires the import grant. The materializer's exec policy stays the + platform's. The conservative always-false alternative was rejected: an inert + imported skill with no visible reason is a worse failure than an explicit two-line + approval card. workspace-import.md §5.2; change-set.md §5.1.3. +- **Acknowledgement channel realism.** No fully trusted channel exists from inside a + Daytona sandbox. The design makes a forged acknowledgement harmless instead of + pretending to secure the channel: worst case is a stale model-visible catalog, never + a privilege escalation. adapter-matrix.md §4.3. + ## Settled by the contracts (no longer open) - Binary and unsupported files reject the whole import by default; `on_unsupported: From 2ba04b84c73f6323a18fa5bb27b2517ca674afb2 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Tue, 4 Aug 2026 22:19:49 +0200 Subject: [PATCH 07/36] docs(design): gate 3 verdict: GO for S4+S5; five contract corrections named --- .../research/design-gate3-review-codex.md | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 docs/design/agent-config-editing/research/design-gate3-review-codex.md diff --git a/docs/design/agent-config-editing/research/design-gate3-review-codex.md b/docs/design/agent-config-editing/research/design-gate3-review-codex.md new file mode 100644 index 0000000000..b6621a5c9d --- /dev/null +++ b/docs/design/agent-config-editing/research/design-gate3-review-codex.md @@ -0,0 +1,87 @@ +# Third design gate verdict: NO-GO + +The design is not convergence-complete. S4 and S5 can start. S1a and S3a cannot. + +The team fixed several gate-2 findings correctly, especially the atomic commit contract and multi-source authorization. But five implementation-significant contradictions remain. + +## Must-fix findings + +1. **Draft `read_config` cannot execute.** The contract binds `target.run_revision_id` even though that value is absent on a draft run ([read-config.md](/home/mahmoud/code/agenta-2/docs/design/agent-config-editing/contracts/read-config.md:33)). Current binding code treats every absent binding as a hard failure ([direct.ts](/home/mahmoud/code/agenta-2/services/runner/src/tools/direct.ts:228)). The cited test only proves that `workflow.is_draft` resolves; it does not prove optional bindings work ([tool-direct.test.ts](/home/mahmoud/code/agenta-2/services/runner/tests/unit/tool-direct.test.ts:308)). + +2. **The single-text approval can show the wrong diff.** It compares the new text against the configuration running in the session, which may be revision N, while the model can correctly supply head revision N+1 as `base_revision_id`. The base check then passes and the commit replaces N+1, even though the user approved an N-to-new diff ([workspace-import.md](/home/mahmoud/code/agenta-2/docs/design/agent-config-editing/contracts/workspace-import.md:697)). The old side must come from the exact `base_revision_id`, or the API must validate an approved old-value digest. + +3. **The import contract still contradicts itself.** + + - Authorization requires strict canonical serialization, but workspace import still mandates the lenient `canonicalJson` for `contentDigest` ([execution-authorization.md](/home/mahmoud/code/agenta-2/docs/design/agent-config-editing/contracts/execution-authorization.md:71), [workspace-import.md](/home/mahmoud/code/agenta-2/docs/design/agent-config-editing/contracts/workspace-import.md:558)). + - The item manifest still has the obsolete single `allowExecutableFiles` field rather than the two arbitrated grants ([workspace-import.md](/home/mahmoud/code/agenta-2/docs/design/agent-config-editing/contracts/workspace-import.md:590)). + - `find -maxdepth 8` cannot report entries below depth 8, so those entries are silently omitted even though the contract says they must reject or appear as omissions ([workspace-import.md](/home/mahmoud/code/agenta-2/docs/design/agent-config-editing/contracts/workspace-import.md:196), [workspace-import.md](/home/mahmoud/code/agenta-2/docs/design/agent-config-editing/contracts/workspace-import.md:412)). + - The local reader still leaves the native-helper versus weaker-fallback choice to the plan, but the plan does not choose or budget either path. + +4. **The generation digest omits behavior-bearing binding sources.** It hashes only binding destination paths, not the source tokens. Changing a binding from `$ctx.workflow.variant.id` to another context value at the same destination changes the executed arguments but not the generation ([adapter-matrix.md](/home/mahmoud/code/agenta-2/docs/design/agent-config-editing/contracts/adapter-matrix.md:127), [adapter-matrix.md](/home/mahmoud/code/agenta-2/docs/design/agent-config-editing/contracts/adapter-matrix.md:184)). Hash the sorted `{destinationPath, sourceToken}` mapping. + +5. **Acknowledgement and rollout text still contradict the arbitration.** Section 4.3 forbids the relay directory, but Claude §5.3 and rollout step 3 explicitly use it ([adapter-matrix.md](/home/mahmoud/code/agenta-2/docs/design/agent-config-editing/contracts/adapter-matrix.md:343), [adapter-matrix.md](/home/mahmoud/code/agenta-2/docs/design/agent-config-editing/contracts/adapter-matrix.md:424), [adapter-matrix.md](/home/mahmoud/code/agenta-2/docs/design/agent-config-editing/contracts/adapter-matrix.md:502)). The plan also calls the channel “trusted,” although the arbitration’s entire point is that it is untrusted. + +## Gate-2 item rulings + +| Item | Ruling | Reason | +|---|---|---| +| 2. Atomic commit and no-change | **RESOLVED** | `commit-transaction.md` §4.1 restores the embed check, and §5.1 compares both persisted `data` and stored `flags`. | +| 3. `read_config`, draft, scope, description | **PARTIAL** | Base ownership and fail-closed scope defaults are specified, but draft dispatch fails on the absent revision binding. | +| 4. Single-use authorization | **PARTIAL** | Exact serializer and atomic multi-source lifecycle are good, but `workspace-import.md` still directs `contentDigest` to the unsafe serializer. | +| 5. Workspace import | **PARTIAL** | The local attack is correctly understood and Daytona’s limitation is honestly described, but the implementation path is unselected, deep files disappear silently, and the accepted Daytona risk is absent from `plan.md`. | +| 6. Applied-generation invariant | **PARTIAL** | Execution-plan coupling and removal revocation are substantially better. Binding-source semantics and acknowledgement-channel consistency remain wrong. | +| 7. Rewrite slice plan | **RESOLVED** | Engine/transaction, import, and lifecycle slices were split, and lifecycle step 9 is included. New plan defects remain below. | +| 8. Rollout and tests | **PARTIAL** | Tests are much stronger. Deployment order and the kill switch are not safe mixed-version contracts. | + +## New-problem rulings + +| Problem | Ruling | Settlement or remaining gap | +|---|---|---| +| 1. Exact `argsDigest` | **PARTIAL** | Execution authorization §2.3 fixes it, but workspace import §7.2 contradicts the same serializer boundary for `contentDigest`. | +| 2. Multi-source authorization | **RESOLVED** | Execution authorization §3.4 checks policy before reads, verifies the complete set, consumes synchronously, substitutes all values, and cleans up atomically. | +| 3. Local TOCTOU | **PARTIAL** | Workspace import §3.2 specifies the correct descriptor-relative walk, but §3.4 still permits a weaker fallback and the plan chooses neither. | +| 4. Daytona equality and race | **PARTIAL** | Descendant comparison is fixed. The race is now acknowledged, but §6.5 requires the plan to record the accepted risk and it does not. | +| 5. Draft `is_draft` | **UNRESOLVED** | `run_is_draft` resolves, but the additional absent `run_revision_id` binding makes the whole draft call fail. | +| 6. Persisted equality and embed check | **RESOLVED** | Commit transaction §§4.1 and 5.1 settle both. The current code premises were also verified in `service.py` and `dao.py`. | +| 7. Forgeable acknowledgement | **PARTIAL** | The harmless-forgery argument can work, but the channel and rollout sections still instruct implementers to use the relay directory. | +| 8. Generation semantics | **PARTIAL** | Most execution semantics are included, but context-binding source tokens are omitted. | +| 9. Approval coverage for `set + value_from` | **PARTIAL** | The operation and presentation exist, but the displayed old value is not bound to the commit base. | +| 10. Import policy versus stored capability | **PARTIAL** | The four-layer model is directionally correct. The manifest and decisions still use the obsolete field, and the claimed human owner disappears on the ungated explicit-allow path. | +| 11. Stale decisions | **PARTIAL** | The six open calls are deduplicated, but the contract-phase paragraph still names `allow_executable_files` on `value_from` ([decisions.md](/home/mahmoud/code/agenta-2/docs/design/agent-config-editing/decisions.md:50)), and the plan still references old call numbers 12, 10, 11, and 4. | + +## Arbitration rulings + +1. **Text-file `value_from` on `set`: not accepted as written.** The restricted target set and single-file rule are good. The approval must derive old text from the exact `base_revision_id`, not from the running session. If that value cannot be obtained, fail closed rather than claiming a unified-diff approval. + +2. **Four-layer executable split: accept conditionally.** The semantic split is correct: observed bit, import grant, stored capability, platform execution policy. Fix the manifest, stale decisions text, and the incorrect statement that ephemeral `on_executable` is an at-runtime permission. Also decide whether setting the persisted capability always forces approval, independently of generic commit permission. + +3. **Harmless-forgery acknowledgement: accept conditionally.** This is a defensible design only while the runner-side execution plan remains authoritative, removals and permission tightening take effect independently of acknowledgement, and acknowledgement affects model visibility only. Rename it as an untrusted best-effort acknowledgement and remove every relay-directory and “trusted channel” reference. + +## Plan ruling + +The slice cuts meet the narrow gate-2 rewrite requirement, but the plan is not implementation-ready as a whole. + +- The table contains **14 slices, not 15**. +- S1a says “blocked by nothing,” while decisions explicitly say storage normalization blocks S1a/S1b ([plan.md](/home/mahmoud/code/agenta-2/docs/design/agent-config-editing/plan.md:46), [decisions.md](/home/mahmoud/code/agenta-2/docs/design/agent-config-editing/decisions.md:102)). +- S1a also includes engine validation work, so unique-name call 2 either blocks it or must be moved explicitly into S1b. +- The catalog ordering rule is correct: ordered operations must not become model-visible before `read_config`. +- API → SDK catalog → runner is not backward-compatible for `value_from`. An old runner will not resolve the source, and the API engine must reject the surviving `value_from`. Use API support dark, then runner support dark, then catalog enablement. +- The kill switch is not concrete enough. Removing the schema does not prevent a stale harness or replayed call from making the runner read a workspace before the API rejects it. +- The plan has no standalone foundation slice for the adapter contract’s required `ToolCatalogManifest`/`ToolExecutionPlan` split and incoming-request wiring before live reconciliation. + +## Slice authorization + +| Slice | Ruling | Waits for | +|---|---|---| +| S1a | **NO-GO** | Product call 1; likely call 2 unless validation moves to S1b. | +| S1b | **BLOCKED** | Product calls 1, 2, and 6. The plan’s “12” is stale. | +| S2 | **BLOCKED** | Draft optional-binding fix; product calls 4 and 5 unless the fail-closed defaults are formally adopted as final v1 decisions. | +| S3a | **NO-GO** | Strict digest consistency, depth detection, local-reader choice, corrected manifest, and recorded Daytona risk. | +| S3b | **BLOCKED** | Product call 3 plus the generation and authorization consistency fixes. | +| S3c | **BLOCKED** | S3b, base-bound single-text diff, and corrected executable manifest. | +| S4 | **GO** | Independent and adequately specified. | +| S5 | **GO** | Independent lifecycle steps 1–2 only. Do not fold live catalog routing into it. | +| S6–S7b, S7d–S7e | **Dependency-blocked** | Follow their stated lower slices. | +| S7c | **BLOCKED** | Generation mapping fix, acknowledgement contract cleanup, and a separate atomic catalog/execution-plan foundation step. | + +So the blunt answer is: **GO for S4 and S5 only. NO-GO for S1a and S3a as currently defined.** From 80fc336b355bbf3fd313eb9421a612edfe73c60d Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Tue, 4 Aug 2026 22:21:36 +0200 Subject: [PATCH 08/36] docs(design): gate 3 plan corrections: dark rollout, two-point kill switch, S7c0 foundation slice, accepted Daytona risk --- docs/design/agent-config-editing/decisions.md | 9 ++-- docs/design/agent-config-editing/plan.md | 42 ++++++++++++------- 2 files changed, 31 insertions(+), 20 deletions(-) diff --git a/docs/design/agent-config-editing/decisions.md b/docs/design/agent-config-editing/decisions.md index 41a2254b8a..48d8b6defe 100644 --- a/docs/design/agent-config-editing/decisions.md +++ b/docs/design/agent-config-editing/decisions.md @@ -56,10 +56,11 @@ reasoning is in `spikes/engine-spike.md` (D1-D33, O1-O12) and `spikes/runner-spi opts into omission; executable permission is caller-declared and default-deny, never derived from file mode bits; frozen approval bytes never ride tool arguments; a cold resume refuses the old approval and raises a new gate. -- Arbitration: the `value_from` object gains two optional import-policy fields - (`on_unsupported`, `allow_executable_files`). The runner consumes and strips them; - the engine never sees them. change-set.md §5.1 and workspace-import.md §11 - cross-reference this. +- Arbitration (superseded in detail by the four-layer split below): the `value_from` + folder source carries optional import-policy fields that the runner consumes and + strips; the engine never sees them. The current fields are `on_unsupported`, + `on_executable`, and `persist_executable_capability`. change-set.md §5.1 and + workspace-import.md §5.2 are authoritative. - Inline resolution happens only on an explicit allow verdict from the permission plan; a missing authorization for a gated call fails closed. diff --git a/docs/design/agent-config-editing/plan.md b/docs/design/agent-config-editing/plan.md index 9b7b256d94..2dfa008635 100644 --- a/docs/design/agent-config-editing/plan.md +++ b/docs/design/agent-config-editing/plan.md @@ -45,33 +45,43 @@ model-visible in the catalog until `read_config` exists. An agent that can be to | Slice | Content | Blocked by | |---|---|---| -| S1a | The pure engine and the operation schemas, per `contracts/change-set.md` §12's prototype changes. No catalog exposure. | nothing | -| S1b | The commit transaction per `contracts/commit-transaction.md`: one transaction, base check, validation, canonical equality over all persisted fields, no-change response. | product calls 1, 2, 12 | -| S2 | `read_config` per `contracts/read-config.md`, including the editable-scope policy. | product calls 10, 11 for the scope section | -| S3a | The import codec and workspace readers per `contracts/workspace-import.md`. Pure, both platforms. | nothing | -| S3b | The single-use execution authorization per `contracts/execution-authorization.md`, wired into the approval gate. | product call 4 | -| S3c | The approval card: manifest, sizes, digests, diff, executable flags. Minimal frontend. | S3b | -| S4 | The ephemeral `description` on builder tool-call envelopes, shown on tool cards. | nothing | -| S5 | Runner safety + applied-state identity (lifecycle migration steps 1-2). | nothing | +| S1a | The pure engine and the operation schemas, per `contracts/change-set.md` §12's prototype changes. No validation, no catalog exposure. | product call 1 (text normalization decides matching) | +| S1b | The commit transaction per `contracts/commit-transaction.md`: one transaction, base check, ALL validation including unique names, canonical equality over all persisted fields, no-change response. | product calls 1, 2, 6 | +| S2 | `read_config` per `contracts/read-config.md`, including the editable-scope policy. | the draft-binding fix; product calls 4, 5 | +| S3a | The import codec and workspace readers per `contracts/workspace-import.md`, with the descriptor-relative local walk and depth-overflow detection. | the gate-3 import corrections | +| S3b | The single-use execution authorization per `contracts/execution-authorization.md`, wired into the approval gate. | product call 3; the generation fixes | +| S3c | The approval card: manifest, sizes, digests, base-bound diff, the two executable grant lines. Minimal frontend. | S3b | +| S4 | The ephemeral `description` on builder tool-call envelopes, shown on tool cards. | nothing (in progress) | +| S5 | Runner safety + applied-state identity (lifecycle migration steps 1-2). No live catalog routing. | nothing (in progress) | | S6 | Coordinator extraction + shadow routing (steps 3-4). | S5 | | S7a | Lifecycle extraction into units (step 5), behavior unchanged. | S6 | | S7b | In-place routes for workspace files and model (step 6, first half). | S7a | -| S7c | Tool-catalog routes with the trusted acknowledgement channel per `contracts/adapter-matrix.md`. | S7a, spike S2 verdicts | +| S7c0 | Foundation: the ToolCatalogManifest / ToolExecutionPlan split with one shared generation, and per-turn execution-plan wiring (kills the stale run-turn.ts mix). | S7a | +| S7c | Tool-catalog routes with the untrusted best-effort acknowledgement per `contracts/adapter-matrix.md`. | S7c0, spike S2 verdicts | | S7d | MCP reopen with positive native-history verification. | S7a | | S7e | Credential and provider reconciliation, including the Daytona creation-identity split (steps 8-9). | S7a | +Accepted risk, recorded: the Daytona import manifest cannot fully prevent a +content-swap during the read window under an adversarial sandbox. The two-pass check +detects inconsistency; it is not a snapshot. workspace-import.md §6.5 states the attack +and a test asserts the limitation so nobody mistakes it for a defense. + QA gates: the qa teammate tests each slice when it lands (unit suites plus live stories on the dev stack). A regression blocks the slice until fixed. ### Rollout and compatibility -- **Deployment order:** API first (it accepts both delta forms), then the SDK catalog - (it advertises the new schema), then runner images. Each step is backward-compatible - with the previous one. -- **Kill switch:** one API-side setting disables the ordered delta form and - `value_from` acceptance; the catalog reads it and falls back to advertising the - legacy schema. The runner needs no switch of its own: without the catalog schema, no - model emits the new form. +- **Deployment order, dark-first:** the API ships support for the new forms disabled + ("dark"). The runner ships its `value_from` resolution and stripping, also dark. + Only then does the catalog start advertising the new schema. This order exists + because plain API-first is NOT backward-compatible for `value_from`: an old runner + would forward the unresolved source and the API must reject it. +- **Kill switch, two enforcement points:** one flag, read by the API and the runner. + Off means: the catalog advertises the legacy schema only; the API rejects ordered + deltas and any surviving `value_from` with a clear error; the runner refuses to + resolve `value_from` before any workspace read. Both enforcement points exist + because a stale harness or a replayed call can still emit the new form after the + catalog stops advertising it. - **Legacy DTO compatibility:** `extra="forbid"` applies to the new operations form only. The legacy `set`/`remove` form keeps its current tolerance, so old playbooks and stored callers do not start failing. From adeaa0cf38efbbaace18671ddf601b44d01cad44 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Tue, 4 Aug 2026 22:36:22 +0200 Subject: [PATCH 09/36] docs(design): gate 3 fixes across the four affected contracts --- .../contracts/adapter-matrix.md | 115 +++++++-- .../contracts/change-set.md | 69 ++++-- .../contracts/read-config.md | 108 ++++++--- .../contracts/workspace-import.md | 222 ++++++++++++++---- 4 files changed, 391 insertions(+), 123 deletions(-) diff --git a/docs/design/agent-config-editing/contracts/adapter-matrix.md b/docs/design/agent-config-editing/contracts/adapter-matrix.md index a8b0de20bd..1215048d46 100644 --- a/docs/design/agent-config-editing/contracts/adapter-matrix.md +++ b/docs/design/agent-config-editing/contracts/adapter-matrix.md @@ -27,6 +27,10 @@ result. An acknowledgement must be an observation the runner makes, not an action the runner takes. It must identify the generation it confirms. +Every acknowledgement in this contract is an **untrusted best-effort acknowledgement** when the +reporter runs inside a sandbox. Section 4.3 defines what that means and what bounds it. The +table below ranks the SHAPE of the evidence, not its trustworthiness. + | Acknowledgement | Strength | |---|---| | The adapter returns the installed catalog, and the runner compares it to the desired catalog. | Strong. Preferred. | @@ -41,8 +45,9 @@ Every live catalog route follows the same five steps. 1. Build the desired catalog. Compute its generation identifier. 2. Apply the harness-specific mechanism. -3. Wait for an acknowledgement, bounded by a deadline. -4. On acknowledgement, commit the new generation to applied state. +3. Wait for an untrusted best-effort acknowledgement, bounded by a deadline. +4. On acknowledgement, commit the new generation to applied state. The four rules in section + 4.3.2 must hold, or this step is unsafe. 5. On timeout, on a mismatch, or on an error, do not commit. Escalate to reopen. If reopen is not available, escalate to rebuild. @@ -133,7 +138,7 @@ Each entry holds exactly these fields, in this order: | `permission` | Execution plan | Whether a call gates. Changing it changes what an approval means. | | `dispatchKind` | Execution plan | `direct`, `gateway`, `client`, or `relay`. It decides which code path runs the call. | | `dispatchTarget` | Execution plan | For a direct call, the method plus the path template. For a gateway call, the `callRef`. This is WHERE the call goes. | -| `contextBindingPaths` | Execution plan | The sorted list of bound argument paths. Not their values. A new binding changes which argument the runner overwrites. | +| `contextBindings` | Execution plan | The sorted list of `{destinationPath, sourceToken}` PAIRS. Not the resolved values. See the note below. | | `argsIntoPath` | Execution plan | Where the model's arguments land in the body. | | `staticBodyDigest` | Execution plan | A digest of the server-fixed `body` fields. It captures a changed fixed field without copying it. | | `timeoutMs` | Execution plan | A behavior change the approver could reasonably care about. | @@ -141,6 +146,24 @@ Each entry holds exactly these fields, in this order: The list is sorted by `name`. The set of tools is part of the document, so adding or removing a tool changes the generation even when no surviving tool changed. +**`contextBindings` hashes the mapping, not the destinations.** Gate 3, finding 4 caught this. +The gate 2 version hashed `contextBindingPaths`, a sorted list of destination paths alone. That +misses a real behavior change: rewiring a binding from `$ctx.workflow.variant.id` to +`$ctx.workflow.artifact.id` at the SAME destination changes the argument the runner actually +sends, while the destination list stays identical. The generation would not move, and a parked +authorization would still verify against a call that now targets a different workflow entity. + +So the field holds pairs, sorted by `destinationPath`: + +``` +[ {destinationPath: "references.workflow_variant.id", sourceToken: "$ctx.workflow.variant.id"}, + {destinationPath: "trace.parent_id", sourceToken: "$ctx.trace.span_id"} ] +``` + +The `sourceToken` is the literal `$ctx.` token from the descriptor. It is NOT the resolved value. +The token is stable configuration; the value is per-turn data. Section 2.4.2 keeps per-turn data +out. + #### 2.4.2 What is deliberately excluded | Excluded | Reason | @@ -159,7 +182,7 @@ die for no reason and users re-approve constantly. #### 2.4.3 Execution-plan-only changes This is the case gate 2 named as undefined. A change to `permission`, `dispatchTarget`, -`contextBindingPaths`, `argsIntoPath`, `staticBodyDigest`, or `timeoutMs` changes the generation +`contextBindings`, `argsIntoPath`, `staticBodyDigest`, or `timeoutMs` changes the generation even though the model-visible catalog is byte-identical. Two consequences follow, and both are intended. @@ -188,8 +211,12 @@ with an explicit split is simpler than two values that can disagree. authorization still verifies. - Reordering the input tool list does NOT change the generation. - Adding a tool changes the generation even when every existing tool is unchanged. -- Changing a `contextBindings` VALUE source without changing its PATH does not change the - generation. Changing the path does. +- Rewiring a binding's `sourceToken` at an UNCHANGED `destinationPath` DOES change the + generation. This is gate 3 finding 4 and it must have its own test. +- Changing only the resolved VALUE a token points at, with the token unchanged, does NOT change + the generation. +- Reordering the binding list does not change the generation. The pairs sort by + `destinationPath`. ### 2.5 `customTools` and the fingerprint @@ -208,7 +235,7 @@ The order is therefore fixed: 1. Build `ToolCatalogManifest` and `ToolExecutionPlan` with one generation. 2. Make `runTurn` take both from the incoming request. -3. Add the acknowledgement mechanism for the target harness. +3. Add the untrusted best-effort acknowledgement mechanism for the target harness. 4. Only then remove `customTools` from `configFingerprint`. Steps 1 to 3 change no reuse behavior. Step 4 is the only behavior change, and by then the @@ -285,7 +312,7 @@ blocking execution invariant, and this contract adopts it. If either half cannot be confirmed, Pi removal escalates to `restart-runtime`. -### 4.3 Pi acknowledgement, and the channel it needs +### 4.3 Pi untrusted best-effort acknowledgement The extension hook returns the result of `getActiveTools()` after the refresh. The runner compares that set to the desired active set. Equality acknowledges the generation. Any @@ -296,7 +323,7 @@ is right to reject it. The relay directory is sandbox-writable. It is the exact motivated the whole execution-authorization contract. A forged acknowledgement there would advance applied state. -#### 4.3.1 There is no fully trusted channel from inside a Daytona sandbox +#### 4.3.1 No channel from inside a Daytona sandbox can be trusted State this plainly, because it shapes every option below. @@ -310,7 +337,13 @@ The read-once file pattern the runner already uses for the OTLP bearer raises the cost of a forgery. It does not remove it. A same-user process in the sandbox can read the file before the extension does. -So the design must not depend on a trusted acknowledgement. It must make a forged one harmless. +So the design must not depend on the acknowledgement being honest. It must make a forged one +harmless. + +**The mechanism is named "untrusted best-effort acknowledgement", everywhere, without +exception.** Gate 3, finding 5 found that the gate 2 text still called it a channel to be +trusted in places, which invites an implementer to secure it and then rely on it. It cannot be +secured. The guarantee is section 4.3.2, not the message. #### 4.3.2 Make the blast radius small, then authenticate what is left @@ -340,21 +373,29 @@ A fourth rule bounds it further: invent a state; it can only claim that a change the runner already decided to make did happen. -#### 4.3.3 The channel +#### 4.3.3 How the acknowledgement travels -Given the above, the acknowledgement channel is defined as follows. +The transport is deliberately unremarkable, because nothing rests on it. -- **Not the relay directory.** The relay directory is swept, sandbox-writable, and already - carries execution records. Mixing a control-plane message into it invites exactly the - confusion gate 2 flagged. Use a dedicated path the relay sweep never touches. -- **Per-reconciliation nonce.** The runner mints a fresh random nonce for each reconciliation - and delivers it through the read-once `0600` file pattern above. The acknowledgement must echo - it. The nonce is single-use and expires with the reconciliation deadline. +- **Never the relay directory.** The relay directory carries EXECUTION records. It is swept + between turns, and the execution-authorization contract treats every byte in it as hostile. + Putting a control-plane message beside execution records confuses two planes with two + different trust stories. Use a dedicated per-reconciliation path that the relay sweep never + touches, and never read a control message out of the relay directory. +- **Per-reconciliation nonce.** The runner mints a fresh random nonce for each reconciliation and + delivers it through the read-once `0600` file pattern above. The acknowledgement echoes it. The + nonce is single-use and it expires with the reconciliation deadline. - **The acknowledgement carries** the nonce, the pending generation identifier, and a digest of the active tool set. The runner checks all three. -- **Local runs are genuinely trusted.** On a local run the extension executes on the runner host - under the runner's own user. The file channel there is as trustworthy as the runner. The - distinction is worth recording, because local is where this can be tested honestly. + +The nonce raises the cost of a forgery. It does not make the acknowledgement trustworthy, and no +document, comment, or log line may describe it as doing so. A sandbox process that runs arbitrary +code can read the file. The four rules in section 4.3.2 are the guarantee. The nonce is hygiene. + +**Local runs differ, and the difference is worth recording.** On a local run the extension +executes on the runner host under the runner's own user, so the file is as reliable as the +runner itself. Local is therefore where this path can be tested honestly. It does not change the +Daytona story, and the code must not branch on it to skip the section 4.3.2 rules. #### 4.3.4 The stronger option, if it can be built @@ -375,6 +416,9 @@ Claude's acknowledgement is slightly stronger in one respect: the shim reports t issued a `tools/list`, which is an event the shim observes rather than a state it asserts. A forged report still only confirms the pending generation. +It is still an untrusted best-effort acknowledgement, and it must be named that way in code, +comments, logs, and metrics. + ### 4.4 Pi and MCP Pi in the tested version has no MCP client. There is no `*mcp*` module in its distribution and no @@ -426,8 +470,12 @@ The capability key is therefore `{harness, adapterVersion, transport, provider}` The runner does not see Claude's internal refresh. It sees the shim. So the shim is the observer. After it emits the notification, it waits for the client's -`tools/list`. It records which generation it served. It reports that back to the runner over the -relay directory, as in section 4.3. +`tools/list`. It records which generation it served. It reports that back to the runner as an +untrusted best-effort acknowledgement, over the dedicated path in section 4.3.3. **Never over +the relay directory.** + +The shim runs inside the sandbox on Daytona, so its report carries exactly the trust level of +Pi's. Section 4.3.2's four rules bound it identically. No `tools/list` inside the deadline means no acknowledgement. The runner escalates to reopen. @@ -507,7 +555,8 @@ Each step ships alone. No step changes reuse behavior until step 5. change. 2. Make `runTurn` build both from the incoming request. Remove the `env.plan` read at `run-turn.ts:822`. No behavior change. -3. Add the acknowledgement channel over the relay directory. No behavior change. +3. Add the untrusted best-effort acknowledgement path, dedicated and outside the relay + directory. No behavior change. 4. Ship the Pi specs file and the extension hook, plus the Claude stdio shim capability and notification. Route both to reopen still, and log the acknowledgement. This is shadow mode. 5. Compare the shadow logs. Flip Pi and Claude-on-Daytona to `apply-live`. Remove `customTools` @@ -589,8 +638,8 @@ not close the gate. | Gate 2 point | Where it is answered | |---|---| -| New problem 7: the Pi acknowledgement channel is sandbox-writable and forgeable | §4.3 is rewritten. §4.3.1 states that NO fully trusted channel exists from inside a Daytona sandbox, and explains why signing does not fix it. §4.3.2 makes a forged acknowledgement harmless with four runner-side rules, so the worst case is a stale model-visible catalog and never a privilege escalation. §4.3.3 defines the channel: off the relay directory, single-use nonce over the existing read-once `0600` file pattern, echoed with the pending generation and an active-set digest. §4.3.4 names the stronger runner-observed design as the target. §4.3.5 applies the same reasoning to the Claude stdio shim. | -| New problem 8: generation semantics are incomplete for execution-plan-only changes | §2.4 is new. §2.4.1 defines the canonical document with eleven fields per tool, including `permission`, `dispatchKind`, `dispatchTarget`, `contextBindingPaths`, `argsIntoPath`, `staticBodyDigest`, and `timeoutMs`. §2.4.2 excludes rotating credentials and explains the include-what-changes-meaning rule. §2.4.3 defines the execution-plan-only case: the generation advances, parked authorizations fail closed, and the harness session is NOT reopened. §2.4.4 gives six tests. | +| New problem 7: the Pi acknowledgement channel is sandbox-writable and forgeable | §4.3 is rewritten. §4.3.1 states that NO fully trusted channel exists from inside a Daytona sandbox, and explains why signing does not fix it. §4.3.2 makes a forged acknowledgement harmless with four runner-side rules, so the worst case is a stale model-visible catalog and never a privilege escalation. §4.3.3 defines how it travels: never the relay directory, single-use nonce over the existing read-once `0600` file pattern, echoed with the pending generation and an active-set digest. §4.3.4 names the stronger runner-observed design as the target. §4.3.5 applies the same reasoning to the Claude stdio shim. | +| New problem 8: generation semantics are incomplete for execution-plan-only changes | §2.4 is new. §2.4.1 defines the canonical document with eleven fields per tool, including `permission`, `dispatchKind`, `dispatchTarget`, `contextBindings` as sorted `{destinationPath, sourceToken}` pairs, `argsIntoPath`, `staticBodyDigest`, and `timeoutMs`. §2.4.2 excludes rotating credentials and explains the include-what-changes-meaning rule. §2.4.3 defines the execution-plan-only case: the generation advances, parked authorizations fail closed, and the harness session is NOT reopened. §2.4.4 gives eight tests. | | Item 6 status: acknowledgement, generation coupling, transport-specific capability, Pi execution revocation, continuity verification | Unchanged from gate 1. §1, §2.1 to §2.3, §2.5, §3, §4.2, §5.2, §6.2. | Two things this rewrite makes explicit that gate 1 left implied: @@ -607,3 +656,17 @@ Not resolved here, by design: - Whether Pi's ACP surface can report its active tool set, which would replace §4.3.3 with the stronger §4.3.4 design. The spike found no such surface. It needs a live check, not another static read. + +## 12. Gate 3 resolution + +| Gate 3 finding | Where it is answered | +|---|---| +| Finding 4: the generation digest omits binding SOURCES, so rewiring `$ctx.workflow.variant.id` to another token at the same destination changes the executed arguments without changing the generation | §2.4.1 replaces `contextBindingPaths` with `contextBindings`, a list of `{destinationPath, sourceToken}` pairs sorted by destination, with a worked example and a note on why the token and not the resolved value. §2.4.3 and §2.4.4 follow. Two new tests: rewiring a token at an unchanged destination MUST move the generation; changing only the value a token resolves to must NOT. | +| Finding 5: §5.3 and rollout step 3 still used the relay directory, and the plan called the channel "trusted", contradicting the arbitration | The mechanism is renamed **untrusted best-effort acknowledgement** throughout: §1.2, §1.3, §4.3 heading, §4.3.1, §4.3.3, §4.3.5, and rollout step 3. §4.3.3 forbids the relay directory explicitly and says why the two planes must not mix. §5.3 now routes Claude's report over the dedicated path and states that the stdio shim carries exactly Pi's trust level. §4.3.3 states that the nonce is hygiene, not trust, and that no document, comment, or log line may describe it otherwise. | +| Arbitration ruling 3: harmless-forgery accepted conditionally | The three conditions are §4.3.2's rules 1 to 3: the runner-side execution plan stays authoritative, removals and permission tightening take effect independently of any acknowledgement, and acknowledgement affects model visibility only. §11 already records that these are load-bearing rather than defense in depth. | + +Not resolved here, by design: + +- Gate 3's plan ruling, including the missing standalone foundation slice for the + `ToolCatalogManifest` / `ToolExecutionPlan` split, belongs to `plan.md`. §8 step 1 and step 2 + describe the work that slice must contain. diff --git a/docs/design/agent-config-editing/contracts/change-set.md b/docs/design/agent-config-editing/contracts/change-set.md index b7a28a6f7b..3f201dcfc5 100644 --- a/docs/design/agent-config-editing/contracts/change-set.md +++ b/docs/design/agent-config-editing/contracts/change-set.md @@ -266,20 +266,45 @@ The field must already exist and must already hold a string. Parent creation `target_not_found`, and a non-string field is `target_type_mismatch`. A field that does not exist yet has no old text, so it has no honest diff. Use `add_item` for a new skill file. -**Condition 3: the approval shows a unified diff of the old text against the new text.** -The card presents a readable change: the target field, the diff, the line counts, and the -digest of the exact bytes that will be committed. It must not present a byte count alone. -`workspace-import.md` section 8 owns the presentation; runner-spike adds the single-text-file -mode there. - -Two notes on the diff, for the runner to settle: - -- The old side comes from the configuration the runner holds for the current run. That - configuration can be behind the head. The base check catches the drift and answers 409 - (`commit-transaction.md` section 6), so the human never approves a diff that then commits - silently against a different base. -- If the runner cannot obtain the old text, it must show the complete new text and say that - no old text was available. It must never fall back to a byte count. +**Condition 3: the approval shows a unified diff of the old text against the new text, and +the old text comes from the exact revision the operation names.** The card presents a +readable change: the target field, the diff, the line counts, and the digest of the exact +bytes that will be committed. It must not present a byte count alone. +`workspace-import.md` section 8.4 owns the presentation. + +**The old side must come from `base_revision_id`, and from nothing else.** Gate 3, finding +2 and arbitration ruling 1, corrected this. The gate 2 draft took the old text from the +configuration the runner holds for the current run. That is wrong, and the error is silent: + +- The session runs revision N. The model reads the head, which is N+1, and correctly puts + N+1 in `base_revision_id`. +- The runner renders the diff from its own memory, so the human approves an N-to-new diff. +- The base check passes, because the base really is the head. The commit replaces N+1. +- The user approved one change and got another. Nothing reports it. + +The base check cannot catch this, because the base is not stale. Only the diff is. + +So the rule is exact: + +1. The runner fetches the old text from the revision named by `base_revision_id`, at the + operation's target path. `read_config` is the natural way to fetch it. +2. If `base_revision_id` is absent, the call fails. A single-text `set` from a workspace + file needs a named base. This is not a new burden: an ordered delta already requires + `base_revision_id` (`read-config.md` section 10.1). +3. **If the runner cannot fetch that revision's text, for any reason, the call fails + closed.** It does not fall back to session memory. It does not fall back to the complete + new text. It does not fall back to a byte count. It refuses, with + `source_diff_base_unavailable`, and it reads no workspace bytes. + +Failing closed is the right cost here. A refused call tells the agent to retry, and a retry +costs one turn. A diff against the wrong base commits the wrong content with a human +signature on it, and nothing detects it afterwards. + +An alternative exists, and this contract does not choose it: the API could accept a digest +of the approved old value and refuse the commit when the stored value does not match. That +moves the check to the server and survives a lying runner. It also adds a field to the +commit envelope and a second failure mode. Revisit it if the runner-side fetch proves +unreliable. #### 5.1.2 Folder into `set` stays disallowed @@ -712,6 +737,7 @@ see `commit-transaction.md`. | `item_key_undefined` | The value has no derivable key. | yes | | `source_not_found` | The runner could not read the workspace path. | yes | | `source_invalid` | The source is unusable, or `value_from` reached the engine. | no | +| `source_diff_base_unavailable` | A single-text `set` could not read its old text from `base_revision_id`. Runner-owned. Section 5.1.1. | yes | | `source_too_large` | The source is above the byte limit. | no | | `out_of_scope` | The scope policy refuses the target. | no | | `invalid_delta` | Both forms, no form, or an unknown delta field. | no | @@ -797,3 +823,18 @@ Supporting changes: section 2.1 tables and note, section 5.1 table, sections 5.1 The founding use case is covered again. US-1 and #5554 are the oversized instruction file. Section 5.1.1 gives it a path: one workspace file into `["parameters","agent","instructions","agents_md"]`, approved as a unified diff. + +## 15. Gate 3 resolution + +Gate 3 marked arbitration ruling 1 "not accepted as written", and finding 2 says why. + +| Gate point | Answered in | +|---|---| +| Finding 2. The single-text approval can show a diff against the running session while the commit replaces a newer head | Section 5.1.1, condition 3. The old text comes from the revision named by `base_revision_id`, and from nothing else. The worked failure is written out, with the reason the base check cannot catch it: the base is not stale, only the diff is. | +| Ruling 1. If the old value cannot be obtained, fail closed rather than claim a unified-diff approval | Section 5.1.1, condition 3, rule 3. The call refuses with `source_diff_base_unavailable` and reads no workspace bytes. Every earlier fallback is named and forbidden: session memory, the complete new text, and a byte count. | +| The alternative: the API validates an approved old-value digest | Section 5.1.1 records it, states its cost (a new envelope field and a second failure mode), and does not choose it. | + +Supporting changes: section 10 adds `source_diff_base_unavailable`, and the cross-reference +now points at `workspace-import.md` section 8.4, the single-text mode. + +The restricted target set and the single-file rule are unchanged. Gate 3 accepted both. diff --git a/docs/design/agent-config-editing/contracts/read-config.md b/docs/design/agent-config-editing/contracts/read-config.md index 50529b3387..a20d2876d0 100644 --- a/docs/design/agent-config-editing/contracts/read-config.md +++ b/docs/design/agent-config-editing/contracts/read-config.md @@ -33,7 +33,6 @@ PlatformOp( context_bindings={ "target.workflow_variant_id": "$ctx.workflow.variant.id", "target.run_is_draft": "$ctx.workflow.is_draft", - "target.run_revision_id": "$ctx.workflow.revision.id", }, read_only=True, timeout_ms=15000, @@ -44,34 +43,67 @@ The first binding gives the self-target guarantee. The model cannot name another because the field is stripped from the model-visible schema and filled server-side (`sdks/python/agenta/sdk/agents/platform/op_catalog.py:91`). -### 2.1 Three bindings, because the endpoint cannot compute the draft state +### 2.1 Two bindings, because the endpoint cannot compute the draft state Gate 2, new problem 5, is correct: a variant id alone does not tell the endpoint whether the run is a draft. The variant is the same on a draft run and on a committed run. The draft fact lives in the run context, in the runner, and it must be carried in. -Two more bindings carry it. Both resolve today. No new runner mechanism is needed: +One more binding carries it, and it always resolves. -- `resolveCtxToken` walks any dotted path against the run-context blob. A unit test - already asserts `resolveCtxToken(RUN_CONTEXT, "$ctx.workflow.is_draft")` - (`services/runner/tests/unit/tool-direct.test.ts:314`). -- `RunContextWorkflow` carries `is_draft` and `revision` - (`sdks/python/agenta/sdk/agents/dtos.py:485`). The SDK sets `is_draft = revision is None` - (`sdks/python/agenta/sdk/agents/tracing.py:166`). +**Gate 3, finding 1, corrected this section.** The gate 2 version also bound +`target.run_revision_id` to `$ctx.workflow.revision.id`. That value does not resolve on a +draft run, and an unresolved binding is a hard failure: +`assembleBody` throws `missing run-context value for direct-call binding ''` +(`services/runner/src/tools/direct.ts:228`), and `applyContextBindings` throws the same way +(`:257`). So the third binding would have made every draft `read_config` call fail, which +is the exact run where the draft answer matters most. -So the two new bindings behave like this: +**The fix, chosen from the two options: drop the revision binding.** `read_config` binds +`workflow_variant_id` and `is_draft` only. -| Run | `run_is_draft` | `run_revision_id` | -|---|---|---| -| pinned to a committed revision | `false` | the revision id | -| a playground draft | `true` | absent, because `$ctx.workflow.revision.id` does not resolve | +**The runner change this needs: none.** That is why this option wins over an +optional-binding marker. No new catalog syntax, no new resolver branch, and no new failure +mode. Section 2.2 records the marker as a later option, for the day a real need appears. -Both fields are server-bound. The catalog strips them from the model-visible schema, so -the model cannot claim it is not on a draft run. +`is_draft` is exactly as available as `workflow_variant_id`, so the new binding adds no new +way to fail: -`run_revision_id` is not the answer to the read. The endpoint still answers from the -committed head. The field lets the response state one more useful fact: whether the run's -own revision is still the head. Section 4 carries it as `run_revision_is_head`. +| Run | `$ctx.workflow.variant.id` | `$ctx.workflow.is_draft` | +|---|---|---| +| pinned to a committed revision | resolves | resolves to `false` | +| a playground draft | resolves | resolves to `true` | +| no workflow identity at all | does not resolve | does not resolve | + +The third row already fails today, on the variant binding alone, and `read_config` could +not answer without a variant anyway. `RunContextWorkflow.is_draft` is set unconditionally +whenever the run has any workflow identity, as `revision is None` +(`sdks/python/agenta/sdk/agents/tracing.py:166`), and `False` survives the `exclude_none` +dump. `resolveCtxToken` walks any dotted path against the run-context blob; the unit test +at `services/runner/tests/unit/tool-direct.test.ts:314` asserts the `is_draft` token +resolves. + +**What we give up.** The gate 2 draft said the response would carry +`run_revision_is_head`, computed from the bound revision id. That field is removed from +section 4. No requirement asked for it. It was a convenience, and it is not worth a runner +mechanism. + +### 2.2 The optional-binding marker, if it is ever needed + +A future op may really need a binding that can be absent. Two things must then change +together, and neither is in scope now: + +1. The catalog needs a way to mark a binding optional. A separate + `optional_context_bindings` dict is clearer than a token suffix, because it stays + readable in the op definition and it cannot be confused with a token value. +2. `assembleBody` and `applyContextBindings` must, for an optional binding only, delete + the path and continue instead of throwing. + +**A defect to fix when that work happens, or sooner.** The `assembleBody` docstring already +claims the behavior the code does not have: "a token that does not resolve is left unset +(the field is simply absent)" (`services/runner/src/tools/direct.ts:208`). The code throws. +A reader who trusts the comment will design another broken binding, exactly as this +contract did. Correct the comment even if nothing else changes. The op needs a new endpoint. The existing retrieve endpoint returns a whole revision. It cannot do partial reads, it cannot receive these bindings, and it returns fields the model @@ -91,7 +123,6 @@ must not see. "properties": { "workflow_variant_id": {"type": "string"}, "run_is_draft": {"type": "boolean"}, - "run_revision_id": {"type": "string"}, "path": {"$ref": "#/$defs/Target"} } }, @@ -107,7 +138,7 @@ name in an operation. An absent `path` means the whole readable configuration. -`workflow_variant_id`, `run_is_draft`, and `run_revision_id` are server-bound. Section 2.1 +`workflow_variant_id` and `run_is_draft` are server-bound. Section 2.1 explains them. They are stripped from the model-visible schema, so the model never writes them. @@ -134,7 +165,6 @@ Examples: }, "base_revision_id": "019c...", "is_draft": false, - "run_revision_is_head": true, "path": ["parameters", "agent", "llm"], "value": {"model": "openai/gpt-5", "extras": {"reasoning_effort": "high"}}, "bytes": 74, @@ -148,9 +178,6 @@ Examples: the commit wants. Section 10.1 makes this the single rule, on every kind of run. - `is_draft` comes from the `$ctx.workflow.is_draft` binding of section 2.1. The endpoint echoes it; it does not compute it. Section 10 explains what it means for the answer. -- `run_revision_is_head` compares the bound `run_revision_id` with the head the endpoint - just read. It is `null` on a draft run, because the run has no revision. It tells the - agent whether the configuration it is running is still the head. - `path` echoes the resolved target, so a truncated model context still knows what it got. - `value` is the raw value at that path. @@ -472,9 +499,11 @@ want to persist it, we do that on purpose, with a decision. 5. **Draft reads, later.** Section 10 accepts that a draft run reads the head. RFC Q3 Option C (the runner answers from memory) stays parked. If users find the caveat confusing, that option comes back. -6. **`run_revision_is_head` on a stale run.** The field tells an agent that its running - configuration is behind the head. This contract does not say what the agent should do - about it. A tool description line is probably enough. Decide during the slice. +6. **A stale running configuration.** An agent on a committed run can be running revision + N while the head is N+1. This contract no longer reports that, because the field that + reported it needed the removed binding (section 2.1). The commit still fails safely + with 409. If agents need the earlier warning, add it to the endpoint from the head read + plus the run's own revision, carried some other way. ## 14. Gate 2 resolution @@ -482,13 +511,28 @@ Gate 2 marked item 3 PARTIAL. New problem 5 restates the first point. | Gate point | Answered in | |---|---| -| The catalog binds only `workflow_variant_id`, so no draft flag reaches the endpoint | Section 2.1. Two more server-bound context bindings, `$ctx.workflow.is_draft` and `$ctx.workflow.revision.id`. Both resolve through today's `resolveCtxToken`; the unit test at `services/runner/tests/unit/tool-direct.test.ts:314` proves the first one. | +| The catalog binds only `workflow_variant_id`, so no draft flag reaches the endpoint | Section 2.1. One more server-bound context binding, `$ctx.workflow.is_draft`. It resolves through today's `resolveCtxToken`; the unit test at `services/runner/tests/unit/tool-direct.test.ts:314` proves it. | | The draft claim must be implemented or dropped | Implemented. Section 2.1 carries the flag in, section 4 echoes it, and section 10 states what it means. The endpoint never computes it. | | §4 and §10 contradict each other about `base_revision_id` | Section 10.1. One rule: the model always copies it from the read response for an ordered delta. The state source is the model's own context, carried call to call. The runner defaults it only for a legacy delta on a committed run. | | Calls 10 and 11 leave the security scope unfinished | Section 11.1.1. Both take the fail-closed default in v1, with the reason that widening an allow-list later is additive and narrowing it is a breaking change. Section 13 keeps both open for Mahmoud. | -Supporting changes: section 3 request fields, section 4 response fields, section 11.1 -rewritten as an allow-list, and section 13 items 1 to 3 and 6. - `commit-transaction.md` section 8 now points at section 10.1, so the two contracts state one rule. + +## 15. Gate 3 resolution + +Gate 3 marked item 3 PARTIAL and new problem 5 UNRESOLVED. Both name one defect. + +| Gate point | Answered in | +|---|---| +| Finding 1. The `target.run_revision_id` binding makes every draft call fail, because `direct.ts:228` treats an absent binding as a hard failure | Section 2.1. The binding is removed. `read_config` binds `workflow_variant_id` and `is_draft` only. The chosen option needs **no runner change**, which is why it beats an optional-binding marker. | +| The cited test proves only that `is_draft` resolves, not that optional bindings work | Section 2.1 no longer claims optional bindings work. It claims only what the test proves, and it adds the table showing that `is_draft` fails in exactly the cases where `workflow_variant_id` already fails. | +| New problem 5. Draft `is_draft` | Resolved. The draft call now dispatches, because no binding on the op can be absent. | + +Supporting changes: section 3 drops `run_revision_id` from the request, section 4 drops +`run_revision_is_head` from the response, and section 13 item 6 records what that costs. + +Section 2.2 records the optional-binding marker as a later option, and it reports a defect +found while checking this: the `assembleBody` docstring at +`services/runner/src/tools/direct.ts:208` describes an absent binding as "left unset", +while the code throws. The comment misled this contract once already. Fix it. diff --git a/docs/design/agent-config-editing/contracts/workspace-import.md b/docs/design/agent-config-editing/contracts/workspace-import.md index da94cdad1b..04f352212a 100644 --- a/docs/design/agent-config-editing/contracts/workspace-import.md +++ b/docs/design/agent-config-editing/contracts/workspace-import.md @@ -147,19 +147,32 @@ The descriptor walk in section 3.2 defends against the adversarial process, on t Nothing in this contract fully defends against the adversarial process on the Daytona path. Section 6.5 says so. -### 3.4 If the descriptor walk cannot be built +### 3.4 The descriptor walk is required for v1 -If the plan decides the native helper is not worth its cost for v1, the local path falls back to -a path-based walk with `O_NOFOLLOW`. That is acceptable only with all three of these: +**Decided in answer to gate 3, finding 3.** The descriptor-relative walk in section 3.2 is +REQUIRED. The path-based fallback is a rejected alternative. The plan must budget the work. -1. The contract, the plan, and the code comment all state that the local path defends against the - confused agent and not against an adversarial process. -2. The fallback is not described as a TOCTOU defense anywhere. -3. `contentDigest` still binds what was read. An attacker who wins the race changes what the - human approves, and the human still sees the substituted content on the card before approving. +The gate 1 and gate 2 versions of this section left the choice open, "if the plan decides the +native helper is not worth its cost". Gate 3 is right that this is not a choice a plan can defer: +the two options have different threat models, and the rest of this contract is written for the +stronger one. Leaving it open meant no reader could tell which contract they were implementing. -Point 3 is the real backstop in the fallback case, and it is weaker than it sounds: it works only -because a human reads the card. It is not a technical control. +**The rejected alternative, recorded.** A path-based walk with `O_NOFOLLOW` is cheaper. It needs +no native helper. It is rejected because it does not defend against a replaced intermediate +directory, which section 3.2 shows is the attack that matters. Its only remaining backstop would +be a human reading the approval card, and a human reading a card is not a technical control. + +If a later decision reverses this, three things must change together, and none of them may be +skipped: + +1. This section, section 3.2, and section 3.3 must all say that the local path defends against + the confused agent and not against an adversarial process. +2. The intermediate-directory test in section 10 becomes a recorded known failure. It is never + deleted and never marked passing. +3. `plan.md` must record the accepted risk, in the same place it records the accepted Daytona + risk from section 6.5. + +Until that reversal happens, treat the fallback as out of scope. ### 3.5 Symbolic links are refused, not followed @@ -360,9 +373,18 @@ The runner reads each file's owner-execute bit. It uses it for three things. 2. It reports every executable file in the manifest and on the approval card. 3. It compares the set against `on_executable` and rejects the import when the grant is absent. -At run time an executable file needs three independent yes answers: `on_executable: "import"` at -the moment of import, the stored `allow_executable_files`, and the sandbox execution policy in -`resolveSkillDirs`. That is the intended depth, and each answer has a different owner. +Three independent yes answers must line up before a bundled file ever becomes executable, and +they do NOT all act at the same time. Gate 3 is right that the gate 2 wording blurred this. + +| Answer | When it acts | Owner | +|---|---|---| +| `on_executable: "import"` | At IMPORT time, once. It decides whether the bits may be read and carried at all. It is never consulted again. | The caller, plus the human approver | +| The stored `allow_executable_files` | At RUN time, on every materialization | The stored configuration | +| The materializer's `execPolicy` | At RUN time, on every materialization | The platform | + +`on_executable` is an ephemeral import grant. It is not a runtime permission, and it is never +persisted. Saying it is "checked at run time" would imply the import grant survives into the +revision, which is exactly the conflation the four-layer split exists to remove. ### 5.5 Two source shapes, two schemas @@ -430,6 +452,34 @@ Three framing rules make this safe. 3. **`-maxdepth 8`** bounds the walk inside the command, so a deep or cyclic tree cannot make the command run long. +**`-maxdepth 8` alone is not safe, and the gate 1 text missed this.** `find` with `-maxdepth 8` +does not report an entry below depth 8. It does not warn either. So a file at depth 9 simply is +not in the manifest, and the import commits a skill that silently lacks it. Section 4.2 says a +too-deep entry is unsupported and must reject or appear as an omission. A silent disappearance +is neither. + +So the runner runs a **depth-overflow probe** beside the manifest command: + +``` +find -mindepth 9 -printf 'x' -quit +``` + +`-quit` stops at the first match, so the probe costs one entry, not a full walk. Any output at +all means the tree goes deeper than the walk. The runner then treats the whole source as +carrying too-deep entries and applies `on_unsupported`: + +- under `reject`, the import fails with `source_unsupported_content` and names the depth limit; +- under `omit`, the import proceeds and the manifest records one omission entry with reason + `too_deep`. + +The probe reports existence, not the paths. That is deliberate. Listing every too-deep path +could be unbounded, and the user only needs to know that the tree exceeds the limit. When the +user needs the paths, they run their own `find`. + +The local reader has the same obligation. Its descriptor walk stops at depth 8, so it must +record that it stopped rather than return quietly. Section 4.2's rule is the same on both +readers. + The runner runs two more commands to resolve paths. Both matter, and the gate 1 version got the comparison wrong. @@ -560,9 +610,20 @@ the manifest. Those are inputs, not the committed value. The value must serialize the same way every time, or the digest is useless. - `files[]` is sorted by `path`, using byte-wise comparison of the UTF-8 encoding. -- Object keys are sorted by the canonical serializer. -- The serializer is `canonicalJson` in `services/runner/src/responder.ts`, the same function the - execution authorization uses. +- Object keys are sorted by the serializer. +- **The serializer is `strictCanonicalJson`**, defined in `execution-authorization.md` section + 2.3.3. It is NOT `canonicalJson` from `services/runner/src/responder.ts`. + +The gate 1 version of this section named `canonicalJson`. That was wrong, and it contradicted +the authorization contract. `canonicalJson` calls `normalizeJsonish`, which parses any string +that looks like a JSON object or array and replaces the string with the parsed value. A skill +body is a string. A file's content is a string. Either can look like JSON. So the lenient +serializer would give two different skill values the same `contentDigest`. + +`contentDigest` and `argsDigest` must sit on the same side of the serializer boundary. The +authorization verifies both at consume time (`execution-authorization.md` section 3.2), so a +lenient `contentDigest` would reopen the exact substitution hole the strict `argsDigest` closes. +One rule, stated once: **every digest that authorizes an execution uses the strict serializer.** ### 7.3 The manifest digest @@ -601,8 +662,9 @@ mint time. intent, // "add" | "replace" totalBytes, fileCount, - allowExecutableFiles, // the caller's explicit policy - executableFiles: [path], + onExecutable, // "reject" | "import" -- the import grant (layer 2) + persistExecutableCapability, // boolean -- the stored capability (layer 3) + executableFiles: [path], // the observed bits (layer 1) omitted: [{path, reason, bytes}], descriptionText, bodyDigest, @@ -624,7 +686,8 @@ The card shows, in this order: 2. The source path. 3. The omission section, when `omitted` is non-empty. This comes before the content, because it is what the user is most likely to miss. -4. The executable section, when `executableFiles` is non-empty. It names the policy value. +4. The executable section, when `executableFiles` is non-empty. It shows the two grants on two + separate lines, per section 5.2. 5. The description, in full. 6. The body, or a diff against the current body for a replace. 7. The file list, with sizes. @@ -667,13 +730,13 @@ an oversized instruction file, so this path must exist and it must read well. newBytes, newLines, newDigest, - oldAvailable, // boolean - oldBytes, // present only when oldAvailable - oldLines, // present only when oldAvailable - oldDigest, // present only when oldAvailable - diff, // present only when oldAvailable - addedLines, // present only when oldAvailable - removedLines, // present only when oldAvailable + baseRevisionId, // the operation's base_revision_id. Always present. + oldBytes, + oldLines, + oldDigest, // the digest of the old text AS FETCHED FROM baseRevisionId + diff, + addedLines, + removedLines, contentDigest, // equals newDigest in this mode catalogGeneration } @@ -682,6 +745,8 @@ an oversized instruction file, so this path must exist and it must read well. There is no `itemName`, no `files`, no `omitted`, and no executable section. A `set` writes one string. None of those concepts applies. +There is also no `oldAvailable` flag any more. Section 8.4.3 explains why it is gone. + #### 8.4.2 What the card shows, with old text available 1. The target field, by its readable name. "Replace the instructions" or "Replace the body of @@ -694,38 +759,54 @@ string. None of those concepts applies. The diff is the substance of the card. Items 4 to 6 support it. They never replace it. -The old side comes from the configuration the runner holds for the current run. That -configuration can be behind the head. This is safe, because the base check answers 409 on drift -(`commit-transaction.md` section 6), so the human never approves a diff that then commits -silently against a different base. The card does not need to warn about this. +**The old side comes from the exact `base_revision_id` the operation carries. It never comes +from the configuration running in the session.** -#### 8.4.3 What the card shows, with old text unavailable +Gate 3, finding 2 found the hole in the gate 2 wording, and it is a real one. The session may be +running revision N. The model may correctly supply head revision N+1 as `base_revision_id`, +because it read the head with `read_config`. If the card diffs against the session's revision N, +the human approves an N-to-new change. The base check then passes, because the base really is +N+1, and the commit replaces N+1 with text the human never compared against it. Nothing fails, +and the wrong thing commits. -The runner may not hold the old text. The field may sit outside the configuration the run -carries, or the run may carry no configuration at all. +So the runner fetches the old text at `base_revision_id`, by the operation's own target path. +The card renders that text as the old side, and the manifest records `baseRevisionId` and +`oldDigest` beside it. Approval then means one thing: this exact old text becomes this exact new +text, on this exact base. -In that case the card shows the **complete new text**, and it says plainly why: +The base check in `commit-transaction.md` section 6 still runs. It catches a head that moves +between the approval and the commit. It does not substitute for fetching the correct old side, +because it compares revision identifiers and never compares the text the human read. -``` -No previous text was available, so this shows the complete new content. -``` +#### 8.4.3 When the old text cannot be fetched: fail closed -Two rules bind this case. +**Decided in answer to gate 3, arbitration ruling 1.** If the runner cannot obtain the old text +at `base_revision_id`, the operation FAILS. The runner does not show a card, does not mint an +authorization, and does not commit. -- **The new text is shown in full.** It is not truncated to a preview. The human has no diff to - read, so the full text is the only thing that makes the change reviewable. -- **The card never falls back to a byte count and a path.** That is the failure mode condition 3 - exists to prevent, and it is the one this mode must never reach. +The error is `source_base_unavailable`. It is retryable, because a transient fetch failure is +the common cause. -If the complete new text exceeds the size the interface can render, the interface scrolls it. It -does not summarize it. The 200 000-byte per-file cap in section 4.4 bounds the worst case. +This replaces the gate 2 rule, which showed the complete new text and said no old text was +available. That rule was wrong for this mode. A `set` REPLACES a field that already holds a +string; `change-set.md` section 5.1.1 requires the target to exist and to hold a string, so an +old text always exists somewhere. "Unavailable" therefore never means "there is none". It means +the runner failed to fetch it. Presenting a fetch failure as a complete-content approval invites +the human to approve a replacement without seeing what it replaces, which is the one thing this +mode exists to prevent. + +The complete-new-text presentation still applies in the ITEM mode, where a genuinely new skill +body has no predecessor. See section 8.2. It has no place here. + +The rule stated once, for both modes: **the card never shows only a byte count and a path, and +the single-text mode never shows a new text without the old text it replaces.** #### 8.4.4 Truncation rules, single-text mode | Element | Rule | |---|---| | Diff, old text available | Capped at 400 lines, matching the item mode's replace rule. Beyond that, show the first 400 diff lines, then the changed-line counts and both digests. | -| New text, old text unavailable | **Never truncated.** See section 8.4.3. | +| New text | Shown only inside the diff. There is no no-old-text presentation in this mode. See section 8.4.3. | | Line counts and digests | Never truncated. | The 400-line diff cap is a display cap only. The card must state that `contentDigest` covers the @@ -755,6 +836,7 @@ full diff and the full new text on demand, served from the frozen value. | `source_read_failed` | A daemon or filesystem error. | | `source_timeout` | The import passed its deadline. | | `source_cancelled` | The turn aborted. | +| `source_base_unavailable` | The old text could not be fetched at `base_revision_id`. Single-text mode only. Retryable. | Every code carries the offending paths, up to 20, and a count of the rest. @@ -780,6 +862,12 @@ Every code carries the offending paths, up to 20, and a count of the rest. FAILURE with a reference to section 3.4. It must not be deleted, and it must not be marked passing. +**Depth overflow.** +- A file at depth 9 is DETECTED, not silently omitted. Under `reject` the import fails with + `source_unsupported_content`. Under `omit` the manifest carries one `too_deep` omission. +- The local descriptor walk reports the same condition at the same depth. +- The probe costs one entry, not a full walk. Assert the command shape, not only the outcome. + **TOCTOU, Daytona.** - Change a file's size between the manifest and the read. The import must reject with `source_changed_during_read`. @@ -812,18 +900,23 @@ Every code carries the offending paths, up to 20, and a count of the rest. - A folder source on `set` is `source_invalid`, refused before any content read. - A source matching more than one file is `source_invalid`. - A policy field on a file source is refused by the schema. -- With old text available, the card renders a unified diff plus changed-line counts. -- With old text UNAVAILABLE, the card renders the COMPLETE new text and states why. Assert on - the full text, not on its presence. -- The card never renders only a byte count and a path. Assert this for both the available and - the unavailable case. +- The card renders a unified diff plus changed-line counts. +- The old side is fetched from `base_revision_id`, NOT from the session's configuration. Run the + session at revision N, supply N+1 as the base, and assert the diff's old side equals the text + at N+1. This is gate 3 finding 2 and it must have its own test. +- A failure to fetch the old text produces `source_base_unavailable`. No card is shown and no + authorization is minted. +- The card never renders only a byte count and a path. - A diff longer than 400 lines truncates the DIFF and still states that the digest covers the full text. -- New text longer than 400 lines with no old text is NOT truncated. +- `oldDigest` in the manifest matches the text actually fetched from the base. - A target that does not exist is `target_not_found`. A non-string target is `target_type_mismatch`. Neither reads any content. **Digest.** +- `contentDigest` uses `strictCanonicalJson`, not `canonicalJson`. A skill body holding the text + `{"x":1}` must NOT digest the same as a skill body holding the object. This is gate 3 finding + 3 and it guards the serializer boundary from the import side. - Two imports of an unchanged folder give the same `contentDigest`. - Changing one byte in one file changes `contentDigest`. - The value sent to the API digests to the approved `contentDigest`. @@ -940,3 +1033,30 @@ Not resolved here, by design: - Gate 2 item 7, the slice plan, belongs to `plan.md`. §2.3, §3.2, and §6 each name work the plan must budget. + +## 14. Gate 3 resolution + +| Gate 3 finding | Where it is answered | +|---|---| +| Finding 3a: `contentDigest` still named the lenient `canonicalJson`, contradicting the authorization contract | §7.2 now names `strictCanonicalJson` from `execution-authorization.md` §2.3.3, explains that a skill body or a file's content is a string that can look like JSON, and states the boundary once: every digest that authorizes an execution uses the strict serializer. §10 adds a test asserting the two digests differ. | +| Finding 3b: the item manifest still carried the obsolete single `allowExecutableFiles` | §8.1 replaces it with `onExecutable` and `persistExecutableCapability`, labelled by layer, with `executableFiles` kept as the observed bits. §8.2 item 4 now shows two lines, per §5.2. | +| Finding 3c: `find -maxdepth 8` silently omits deeper entries | §6.2 adds a depth-overflow probe, `find -mindepth 9 -printf 'x' -quit`, which costs one entry rather than a full walk. Overflow applies `on_unsupported`: reject fails loudly, omit records one `too_deep` omission. The local descriptor walk carries the same obligation. §10 adds three depth tests. | +| Finding 3d: the local reader left the native-helper versus fallback choice to a plan that chose neither | §3.4 is rewritten. The descriptor-relative walk is REQUIRED for v1. The path-based fallback is a recorded rejected alternative, with the three things that would have to change together if the decision is ever reversed. | +| Finding 2 / arbitration ruling 1: the single-text diff compared against the session's configuration, so a correct `base_revision_id` of N+1 could commit against text the human never saw | §8.4.2 now fetches the old side from the exact `base_revision_id`, never from the session, and works the N versus N+1 case through. §8.4.1 adds `baseRevisionId` and `oldDigest` to the manifest and drops `oldAvailable`. §8.4.3 replaces the complete-new-text presentation with a fail-closed `source_base_unavailable`, because a `set` always replaces an existing string, so "unavailable" means a fetch failure and never "there is none". §9 adds the code. §10 adds a dedicated N/N+1 test. | +| Arbitration ruling 2: `on_executable` was wrongly described as an at-runtime permission | §5.4 replaces the three-yes-answers sentence with a table separating what acts at import time from what acts at run time, and states that the import grant is ephemeral and never persisted. | + +One product call this contract cannot make: + +- Gate 3 arbitration ruling 2 asks whether setting the persisted capability must always force + approval, independently of the generic commit permission. This contract does not decide it. + The conservative reading is yes: `persist_executable_capability: true` grants a durable + runtime capability, which is a different question from whether the agent may commit at all. + It needs an owner's answer before S3c. + +Not resolved here, by design: + +- The accepted Daytona race risk from §6.5 must be recorded in `plan.md`. Gate 3 finding 4's + ruling notes it is still missing there. This contract states the risk; the plan must accept it + in writing. +- `decisions.md` still names `allow_executable_files` on `value_from` in its contract-phase + paragraph. That is the pre-split name. §5.2 and §11 carry the current one. From 5f512e2f36664539bb6a6fe112b00ac8a0292636 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Tue, 4 Aug 2026 22:40:40 +0200 Subject: [PATCH 10/36] docs(design): status: S4 and S5 landed on stacked lanes --- docs/design/agent-config-editing/status.md | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/docs/design/agent-config-editing/status.md b/docs/design/agent-config-editing/status.md index a601eef53f..4eb5adbe90 100644 --- a/docs/design/agent-config-editing/status.md +++ b/docs/design/agent-config-editing/status.md @@ -1,6 +1,23 @@ # Status -Updated: 2026-08-04 evening, by team-lead. +Updated: 2026-08-04 night, by team-lead. + +## Implementation state + +Two slices are landed on stacked lanes and pushed, with the gate-3 contract fixes: + +- Lane `agent-config-editing-plan`: all design docs and contracts. +- Lane `agent-config-editing-s5` (stacked on plan): runner applied-state identity and + safe teardown. The approval-stale-config bug is now unrepresentable; a config change + stops the sandbox instead of deleting it; a content-identical commit keeps the warm + session. 14 files. +- Lane `agent-config-editing-s4` (stacked on s5): the agent-written description on + builder tool calls, stripped before dispatch, shown on the agent chat tool card. 12 + files. + +The qa teammate is verifying all suites in the main tree. Everything else waits on the +six product calls in `decisions.md` (call 1 gates the engine slice) and a fourth gate +pass over the corrected contracts. ## Where we are From 494a3e5ac1602d74e6953b2a864c620cb279bbbe Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Wed, 5 Aug 2026 09:21:15 +0200 Subject: [PATCH 11/36] docs(design): reviewer briefing: goal, plan, findings, implicit decisions, six open calls --- docs/design/agent-config-editing/BRIEFING.md | 212 +++++++++++++++++++ 1 file changed, 212 insertions(+) create mode 100644 docs/design/agent-config-editing/BRIEFING.md diff --git a/docs/design/agent-config-editing/BRIEFING.md b/docs/design/agent-config-editing/BRIEFING.md new file mode 100644 index 0000000000..b05fca707b --- /dev/null +++ b/docs/design/agent-config-editing/BRIEFING.md @@ -0,0 +1,212 @@ +# Briefing: agent config editing + +This document is for review. It is self-contained: you do not need to open any other +file. Comment inline on any line you disagree with. The six open decisions are at the +end, in section 6. + +## 1. What we want to do + +An agent in the playground edits its own configuration. Today every edit must resend +the full content. That costs many tokens. It silently wipes list entries the agent +forgets to resend. It fails completely above about 4.8 KB (issue #5554), so a large +instruction file or a downloaded skill cannot be committed at all. The agent cannot +read its own configuration before it writes. A stale write silently overwrites other +people's changes. And any configuration change, however small, throws away the warm +session and pays a 12.5-second rebuild. + +The goal: one working stacked PR set where the agent can + +- fix one line of its instructions with a small find-and-replace (US-1), +- change one line of one skill without touching the others (US-2), +- install a large downloaded skill by pointing at its folder (US-3), +- add or remove one tool by name (US-4), +- read its configuration, in parts, before writing (US-5), +- get a loud conflict instead of a silent overwrite when the base moved (US-7), +- and keep its warm session across small changes (US-8). + +Out of scope, decided earlier: running a temporary change without saving it (US-6). + +## 2. The plan, at high level + +Three phases. + +1. **Spikes.** Two Opus teammates prototype the risky parts in throwaway worktrees, to + find the unknown unknowns before we commit to a design. +2. **Contracts, then gates.** We write exact behavior documents ("contracts") for each + piece. An external reviewer (Codex, GPT, highest reasoning) attacks the design. + We fix, it re-reviews. No implementation starts before its gate passes. +3. **Vertical slices.** Small, separately testable increments land on stacked + GitButler lanes. A QA teammate (Sonnet) tests each slice. At the end: PR + descriptions, inline code comments, a final code review, and docs sync. + +## 3. What we did + +- Both spikes worked. The editing engine prototype passed 120 tests, including proof + that the old delta format behaves identically through the new engine. The runner + spike proved the folder-import path end to end. +- We wrote six contracts: the change-set format, the commit transaction, the read + tool, the import boundary, the approval authorization, and the per-harness update + matrix. +- The external reviewer ran three gates. Gate one: rejected the loose design, forced + the contracts. Gate two: found two real security holes on paper (next section). + Gate three: approved two slices, named the last corrections. All corrections are in. +- Two slices are shipped, on stacked lanes, all suites green (1589 runner tests, 778 + SDK tests, 63 web tests, zero failures): + - **Runner safety.** A config change now stops the sandbox instead of deleting it. + A commit that changes nothing keeps the warm session. The stale-config bug on the + approval path is impossible by construction now: the park call no longer has a + field anyone could stamp wrongly. + - **The description field.** Every builder tool call can carry a short agent-written + note, and the tool card shows it. + +## 4. What we discovered + +Things nobody knew before this project, found by the spikes and the reviews. + +1. **Pi and Claude can take live tool updates. Our own delivery blocks it.** Claude + Code already handles the "tool list changed" signal; our shim never announces the + capability. Pi has live tool APIs; we ship its tool specs in an environment + variable that is read once. Only Codex truly needs a session reopen. +2. **The approval path had a stale-config bug.** Change the configuration while a run + waits for approval, and the resumed session was re-labeled with the new + configuration while still holding the old files. Fixed structurally in the shipped + runner slice. +3. **The approval flow for imports was forgeable on paper.** The relay directory is + writable from inside the sandbox, and the existing guard passes every "ask" + record on non-Pi harnesses. The fix is a single-use authorization that binds the + tool call, the argument digest, and the frozen content digest, and fails closed. +4. **A hashing trick allowed argument substitution.** The existing serializer parses + JSON-looking strings, so two different payloads could hash identically. The + authorization now uses a strict serializer that never re-interprets strings. +5. **Pi's edit tool is not exact.** It silently normalizes quotes, dashes, and + whitespace on a failed match. Good for code files, dangerous for configuration. + We take its contract (unique match, all-or-nothing) without the fuzzy fallback. +6. **Today's merge code has an aliasing defect.** The server's deep merge shares + sub-objects with the base, and the delete path mutates the caller's data through + that sharing. Harmless today by accident. The new engine deep-copies, with tests + pinning both behaviors. +7. **The Daytona file API cannot support the import security checks.** It exposes no + permission bits and no symlink information. The import reader uses a one-shot + manifest command instead, and the remaining read-window race is documented as an + accepted risk rather than papered over. +8. **The commit endpoint would have swallowed the new conflict error.** It wraps + errors so a 409 would silently become "nothing committed, success". The contract + names the exclusion it needs. + +## 5. Implicit decisions we made (flag any you want reversed) + +Made by the team during design, recorded in decisions.md, each reversible by a +comment on this file. + +- Every folder import comes from the `imports/` folder in the workspace, not from + anywhere in the workspace. A manifest is not a security boundary a human reads. +- A folder with unsupported files (binary, oversized) rejects the whole import unless + the caller explicitly opts into omission. No silently partial skills. +- Executable permission is four separate things with four owners: the file's own + mode bit (data), an ephemeral import grant (caller + approver), the stored skill + capability (the configuration), and the runtime exec policy (the platform). + Nothing is ever derived from mode bits alone. +- A cold resume after an approved import asks for approval again. We do not store the + approved bytes durably; that would recreate the large-payload problem. +- Importing one text FILE into one text FIELD (for example a new instruction file) is + allowed, approved as a readable diff whose old side comes from the exact base + revision, or the call fails closed. Folders into text fields stay disallowed. +- A removed Pi tool is hidden from the model AND its execution binding is dropped, so + it cannot run even if called. Pi has no true deregister API. +- Embedded (referenced) skills stay unaddressable by name in v1; editing them still + needs the old whole-list write. A stable key for them is future work. +- The acknowledgement that a live tool update reached the harness is treated as + untrusted. A forged acknowledgement can only make the model's tool list stale; it + can never grant execution of anything. +- The new strict field validation applies to the NEW operations format only. Old + saved calls and playbooks keep today's tolerant behavior. +- Rollout is dark-first: API support ships disabled, then runner support disabled, + then the catalog starts advertising. One kill switch, enforced in both the API and + the runner. + +## 6. The open decisions (yours) + +Answer with one line, for example: `1A 2yes 3B 4no 5no 6yes`, or "go with your +recommendations". + +### Decision 1: exact bytes, or clean text once on write? + +The agent edits text by quoting it exactly. But text has invisible variety: curly +quotes from a Mac keyboard, CRLF line endings from Windows, two Unicode forms that +look identical. If stored text holds a curly quote and the agent types a straight +one, the match fails. + +- **Option A, exact bytes.** Store exactly what was sent. A failed match is loud, and + the agent recovers by copying the true text from the read tool. Strong argument: a + skill folder can contain a Windows batch file, and that file NEEDS its CRLF + endings. Cleaning would corrupt it. The external reviewer recommends A. +- **Option B, clean once on write.** Normalize on save (one Unicode form, LF). + Matching almost never fails. But stored bytes change on the next save of old + fields, and file contents would need an exception anyway, which splits the rule. + +**Recommendation: A.** One rule, no corruption risk, and the read-before-write loop +makes the occasional failed match cheap. + +### Decision 2: the unique-name rule + +Named editing needs unique names. Today a config with two skills named `notes` saves +fine and the runner silently drops one. But some existing agents already carry +duplicates, and a global check would make them uncommittable for unrelated changes. + +The proposed rule: a commit may not CREATE a new duplicate; a collection the commit +touches must end up clean; old duplicates elsewhere only warn. + +**Recommendation: confirm.** Stricter strands old agents. Looser keeps addressing +ambiguous forever. + +### Decision 3: does a folder import always need human approval? + +The approval card (name, file list, diff) is the safety control for content the model +never typed. But some runs have no human, for example a nightly automation that +updates its own skills. + +- **Option A, always gate.** Safest. Every unattended import stalls forever. +- **Option B, gate by default; an explicit "allow" policy skips it.** A run with an + explicit allow policy can already execute arbitrary shell commands unattended; an + import gives it no new power. + +**Recommendation: B.** + +### Decision 4: may the agent change its own harness? + +The harness (Pi, Claude, Codex) is the most identity-defining field: changing it +swaps the runtime and forces a full rebuild. If a user says "switch yourself to +Claude", may the agent commit that, or must it point the user to the settings drawer? + +**Recommendation: no, human commit only in v1.** The write-scope is an allow-list; +widening later is one line, narrowing later is a breaking change. + +### Decision 5: may the agent write outside its own agent section? + +The revision also holds workflow-level fields beside `parameters.agent`, including +the service URL and the schemas. Writing the URL would let an agent redirect its own +requests, which is privilege escalation. + +**Recommendation: no.** Commits stay scoped to `parameters.agent`. + +### Decision 6: store what the agent did, for audit? + +A revision shows the result, not the intent. Storing the operations list on the +commit record ("edited two lines of the instructions, added skill pdf-tools from +imports/") makes every agent commit reviewable afterwards. For imports we store the +source path and a content digest, not the bytes. + +**Recommendation: yes.** Small cost, natural companion of the description field. + +## 7. Next items, in order + +1. You answer the six decisions (comments here, or one line in chat). +2. The external reviewer runs a final gate over the corrected contracts. +3. The engine slice starts: the seven operations behind the commit tool, with the + base check and validation. This unlocks US-1, US-2, US-4, US-7. +4. The read tool and the import path follow: US-5, then US-3. +5. The remaining runner work lands in steps: coordinator extraction, lifecycle split, + live updates for instructions, skills, model, and (per harness) tools. +6. One combined live QA session on the dev stack across all user stories. +7. Final code review, PR descriptions, inline comments, docs sync, and the stack is + yours to merge. From 78d2ce1acd368bb095a5c09928f9122b1f83c317 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Wed, 5 Aug 2026 09:35:08 +0200 Subject: [PATCH 12/36] docs(design): briefing expanded: spikes, contracts, discoveries, slices, refactor plan, decision context --- docs/design/agent-config-editing/BRIEFING.md | 490 ++++++++++++++----- 1 file changed, 366 insertions(+), 124 deletions(-) diff --git a/docs/design/agent-config-editing/BRIEFING.md b/docs/design/agent-config-editing/BRIEFING.md index b05fca707b..2ba0651cec 100644 --- a/docs/design/agent-config-editing/BRIEFING.md +++ b/docs/design/agent-config-editing/BRIEFING.md @@ -1,8 +1,8 @@ # Briefing: agent config editing This document is for review. It is self-contained: you do not need to open any other -file. Comment inline on any line you disagree with. The six open decisions are at the -end, in section 6. +file. Comment inline on any line you disagree with. The six open decisions are in +section 9. Everything else is context so those decisions are easy to make. ## 1. What we want to do @@ -30,101 +30,328 @@ Out of scope, decided earlier: running a temporary change without saving it (US- Three phases. -1. **Spikes.** Two Opus teammates prototype the risky parts in throwaway worktrees, to - find the unknown unknowns before we commit to a design. -2. **Contracts, then gates.** We write exact behavior documents ("contracts") for each - piece. An external reviewer (Codex, GPT, highest reasoning) attacks the design. - We fix, it re-reviews. No implementation starts before its gate passes. -3. **Vertical slices.** Small, separately testable increments land on stacked - GitButler lanes. A QA teammate (Sonnet) tests each slice. At the end: PR - descriptions, inline code comments, a final code review, and docs sync. - -## 3. What we did - -- Both spikes worked. The editing engine prototype passed 120 tests, including proof - that the old delta format behaves identically through the new engine. The runner - spike proved the folder-import path end to end. -- We wrote six contracts: the change-set format, the commit transaction, the read - tool, the import boundary, the approval authorization, and the per-harness update - matrix. -- The external reviewer ran three gates. Gate one: rejected the loose design, forced - the contracts. Gate two: found two real security holes on paper (next section). - Gate three: approved two slices, named the last corrections. All corrections are in. -- Two slices are shipped, on stacked lanes, all suites green (1589 runner tests, 778 - SDK tests, 63 web tests, zero failures): - - **Runner safety.** A config change now stops the sandbox instead of deleting it. - A commit that changes nothing keeps the warm session. The stale-config bug on the - approval path is impossible by construction now: the park call no longer has a - field anyone could stamp wrongly. - - **The description field.** Every builder tool call can carry a short agent-written - note, and the tool card shows it. - -## 4. What we discovered - -Things nobody knew before this project, found by the spikes and the reviews. - -1. **Pi and Claude can take live tool updates. Our own delivery blocks it.** Claude - Code already handles the "tool list changed" signal; our shim never announces the - capability. Pi has live tool APIs; we ship its tool specs in an environment - variable that is read once. Only Codex truly needs a session reopen. -2. **The approval path had a stale-config bug.** Change the configuration while a run - waits for approval, and the resumed session was re-labeled with the new - configuration while still holding the old files. Fixed structurally in the shipped - runner slice. -3. **The approval flow for imports was forgeable on paper.** The relay directory is - writable from inside the sandbox, and the existing guard passes every "ask" - record on non-Pi harnesses. The fix is a single-use authorization that binds the - tool call, the argument digest, and the frozen content digest, and fails closed. -4. **A hashing trick allowed argument substitution.** The existing serializer parses - JSON-looking strings, so two different payloads could hash identically. The - authorization now uses a strict serializer that never re-interprets strings. -5. **Pi's edit tool is not exact.** It silently normalizes quotes, dashes, and - whitespace on a failed match. Good for code files, dangerous for configuration. - We take its contract (unique match, all-or-nothing) without the fuzzy fallback. -6. **Today's merge code has an aliasing defect.** The server's deep merge shares - sub-objects with the base, and the delete path mutates the caller's data through - that sharing. Harmless today by accident. The new engine deep-copies, with tests - pinning both behaviors. -7. **The Daytona file API cannot support the import security checks.** It exposes no - permission bits and no symlink information. The import reader uses a one-shot - manifest command instead, and the remaining read-window race is documented as an - accepted risk rather than papered over. -8. **The commit endpoint would have swallowed the new conflict error.** It wraps - errors so a 409 would silently become "nothing committed, success". The contract - names the exclusion it needs. - -## 5. Implicit decisions we made (flag any you want reversed) - -Made by the team during design, recorded in decisions.md, each reversible by a -comment on this file. - -- Every folder import comes from the `imports/` folder in the workspace, not from - anywhere in the workspace. A manifest is not a security boundary a human reads. -- A folder with unsupported files (binary, oversized) rejects the whole import unless - the caller explicitly opts into omission. No silently partial skills. -- Executable permission is four separate things with four owners: the file's own - mode bit (data), an ephemeral import grant (caller + approver), the stored skill - capability (the configuration), and the runtime exec policy (the platform). - Nothing is ever derived from mode bits alone. -- A cold resume after an approved import asks for approval again. We do not store the - approved bytes durably; that would recreate the large-payload problem. -- Importing one text FILE into one text FIELD (for example a new instruction file) is - allowed, approved as a readable diff whose old side comes from the exact base - revision, or the call fails closed. Folders into text fields stay disallowed. -- A removed Pi tool is hidden from the model AND its execution binding is dropped, so - it cannot run even if called. Pi has no true deregister API. -- Embedded (referenced) skills stay unaddressable by name in v1; editing them still - needs the old whole-list write. A stable key for them is future work. -- The acknowledgement that a live tool update reached the harness is treated as - untrusted. A forged acknowledgement can only make the model's tool list stale; it - can never grant execution of anything. -- The new strict field validation applies to the NEW operations format only. Old - saved calls and playbooks keep today's tolerant behavior. -- Rollout is dark-first: API support ships disabled, then runner support disabled, - then the catalog starts advertising. One kill switch, enforced in both the API and - the runner. - -## 6. The open decisions (yours) +1. **Spikes.** A spike is a small throwaway prototype. Its purpose is not to ship + code. Its purpose is to test a design against reality before we commit to it, and + to surface the questions the design forgot to ask. Two Opus teammates each built + one, in isolated copies of the repo, so nothing touched the main tree. +2. **Contracts, then gates.** A contract is a document that says exactly how one + piece must behave: its fields, its rules, its errors. We wrote six. Then an + external reviewer (Codex, a GPT model at its highest reasoning setting) attacked + the whole design. We fixed what it found, and it reviewed again. Three rounds + total. No implementation was allowed to start before its part passed. +3. **Vertical slices.** A slice is a small increment that lands alone, with its own + tests, on its own branch. Slices stack on each other in GitButler lanes, so each + PR shows only its own change. A separate QA teammate (a Sonnet model) verifies + each slice after it lands. + +## 3. The spikes, in detail + +### Spike A: the editing engine (owner: engine-spike, in Python) + +The idea to test: can all edits be expressed as a short list of small operations +("find this text, replace with that", "add this one skill", "remove this one tool"), +applied by one pure function? Pure means: the function takes the old configuration +and the operations, and returns the new configuration. No database, no network. That +purity matters, because the same function can then serve two masters later: the real +commit, and a future "run with a temporary change". + +What it built: the function, about 700 lines, plus 120 tests. The most important +tests are the **legacy parity** tests: they take eleven old-style deltas (the format +agents use today) and run them through BOTH the old server code and the new engine, +and require identical results. That proves we can switch engines without changing the +behavior of anything that exists. + +What it surfaced: 33 small decisions the design had not made (recorded, each with a +reason), and 12 open questions. Examples of both kinds: "what happens when the text +to replace appears twice?" (answer: refuse, ask for more context, like Pi does); +"should an edit create missing parent objects?" (this became part of the contracts); +"what about skills that are references instead of inline content?" (they have no +name to address; deferred to future work, see section 8). + +It also found a real bug in today's production code, described in section 5, finding 6. + +### Spike B: the runner side (owner: runner-spike, in TypeScript), three parts + +**Part 1: prove the folder import.** The story: the agent downloads a skill from the +internet into its sandbox, then commits "add the skill at this folder". For that, the +runner (the service that operates the sandbox) must read the folder, understand it as +a skill (name and description from the SKILL.md header, the text after it as the +body, every other file as a bundled file), and put the result into the commit. The +spike built exactly that and proved it round-trips: a folder written by our own code, +read back by the new code, produces the identical skill. 34 tests. + +The spike's biggest finding was about WHERE this read must happen, and it changed the +design. See section 5, finding 3. + +**Part 2: can a live harness discover new tools?** Today, changing the agent's tool +list forces a full session rebuild. The question: could a running session pick up a +changed tool list without rebuilding? The spike went into the actual installed +packages of all three harnesses and read their code. The answer surprised us and is +finding 1 in section 5. + +**Part 3: pin today's behavior in tests.** Before refactoring anything, you write +tests that assert what the code does TODAY, including its bugs. These are called +characterization tests. When the refactor later changes the behavior on purpose, it +must edit those tests, and that edit is the visible record of the change. The spike +wrote 15 of them, covering the three defects we then fixed in the shipped runner +slice. + +## 4. The contracts: yes, written, six of them + +Each is a file under `docs/design/agent-config-editing/contracts/`. Together they are +the source of truth for the implementation. What each one says, in one breath: + +1. **change-set.md.** The commit format. The two delta forms (old and new, never + mixed), the seven operations, how a target is addressed (a list of steps, where a + step is either a field name or "the list entry named X"), the exact matching rules + for text edits, the error catalog, and the rules for importing files. This is the + contract the engine implements. +2. **commit-transaction.md.** How a commit becomes a revision safely. One database + transaction that locks the variant, reads the head, checks the base revision id, + applies the operations, validates the result, and inserts, so two simultaneous + commits cannot both win. Also: a commit that changes nothing creates no revision + and says so, and a stale base always answers with a clear conflict carrying both + revision ids. +3. **read-config.md.** The read tool. How the agent asks for its configuration or a + part of it, what the response carries (the revision id, the draft flag), the rule + that responses return exact stored text (never cleaned, never truncated: too-large + answers refuse and offer the list of children instead), and the allow-list of + fields the agent may write at all. +4. **workspace-import.md.** The folder import boundary. Imports come only from the + `imports/` folder; how the reader defends against symlink tricks on both sandbox + types; what happens with binary, oversized, or executable files; and what the + human approval card must show (name, file list with sizes, diff, executable + flags). +5. **execution-authorization.md.** The security layer for imports. When a human + approves an import, the runner freezes the exact bytes and issues itself a + single-use authorization bound to that specific tool call and content. At + execution it verifies and consumes it. A missing or mismatched authorization fails + closed. This exists because the sandbox can write into the channel that delivers + tool calls, so without it a malicious process inside the sandbox could swap the + content after approval. +6. **adapter-matrix.md.** The per-harness update table. For each harness (Pi, Claude, + Codex) and each kind of change (model, tools, MCP servers, instructions, skills): + can a live session take the change, or does it need a session reopen, or a full + rebuild? Plus the rule that the runner only believes a change was applied when it + observes an acknowledgement, and that a forged acknowledgement must never grant + anything (worst case: the model sees a stale tool list). + +## 5. What we discovered, with context + +**Finding 1: Pi and Claude can take live tool updates. Our own delivery blocks it.** +Context: we assumed a tool-list change always needs a rebuild. The spike read the +harness code and found: Claude Code already contains a handler for the standard "tool +list changed" notification and refreshes its tools in place; it never fires for us +only because OUR tool server never announces that it supports the capability (one +missing flag). Pi has live APIs to register and hide tools mid-session; they are +unreachable only because WE deliver Pi's tool list in a process environment variable, +which is read once at startup and can never change. Only Codex genuinely bakes its +tool configuration at session creation and needs a reopen. Consequence: live tool +updates are mostly our work, not the harnesses' work. + +**Finding 2: the approval pause could leave a session running stale instructions.** +Context: when a run stops and waits for your approval, the session is parked. The +runner labels a parked session with a checksum of the configuration it was built +from, and reuses the session later only when the label matches the next request. The +bug: on the approval path, the runner stamped the label from the INCOMING request +instead of from what the session actually contained. So: edit the configuration while +a run waits for approval, approve, and the resumed session got the NEW label on the +OLD files. The next turn saw a match and happily reused a session running outdated +instructions. Nothing could detect it. Fixed in the shipped runner slice, and fixed +structurally: the labeling parameter no longer exists (see section 6). + +**Finding 3: the import approval had to move, or the human approves a lie.** +Context: a tool call travels from the harness to the runner through a shared +directory (the "relay"). The original design resolved the folder into bytes at +execution time, AFTER the human approved. But then the approval card can only show +what the model sent: a path string. The human would approve "imports/pdf-tools" +without seeing a single byte of what gets committed. Worse, the sandbox can write +into that relay directory, so a malicious process could put different content behind +the same path after approval. The fix (now in the contracts): resolve the folder +BEFORE the approval, show the real manifest and diff, freeze the bytes, and commit +exactly the frozen bytes under a single-use authorization. + +**Finding 4: two different payloads could produce the same digest.** Context: the +authorization from finding 3 identifies "what was approved" by a digest (a hash) of +the arguments. The existing hashing helper in the runner tries to be clever: if a +string looks like JSON, it parses it first. That means the string `"{\"x\":1}"` and +the actual object `{x:1}` hash the same, so an attacker could substitute one for the +other without changing the digest. The contracts now require a strict serializer for +anything security-bearing: it never reinterprets strings. + +**Finding 5: Pi's edit tool is not actually exact.** Context: we lifted our +find-and-replace rules from Pi's edit tool, which is battle-tested. Reading its +source showed that on a failed match it silently retries with normalized quotes, +dashes, and whitespace. For source code files that is helpful. For configuration it +is dangerous: an edit could land on text the caller did not actually write. We took +Pi's contract (exact match, must be unique, all-or-nothing batches) WITHOUT the +fuzzy fallback. This is also what makes open decision 1 matter. + +**Finding 6: today's merge code mutates data it does not own.** Context: the current +server code that applies an agent's delta copies objects level by level, shallowly. +Branches it does not touch stay SHARED with the input object. The delete step then +deletes through that shared branch, changing the caller's original data. Today +nothing breaks, purely by luck: the input always happens to be a fresh copy. A shared +engine cannot rely on luck, so the new engine deep-copies, and two tests now pin both +the old and the new behavior so any change is visible. + +**Finding 7: the Daytona file API cannot support the import security checks.** +Context: on Daytona (the cloud sandbox), the runner reads sandbox files through +Daytona's file API. That API reports no file permissions and cannot tell a symlink +from a real file. Both matter for imports: the executable bit is content we must +record, and a symlink pointing outside the import folder is the classic escape +trick. The workaround: one shell command inside the sandbox produces a complete +manifest (types, permissions, sizes, real paths) in a single round trip. The +remaining race (content swapped during the read window by a hostile process inside +the sandbox) cannot be fully closed on Daytona; the contract says so honestly and a +test asserts the limitation, so nobody later mistakes the check for a defense. + +**Finding 8: the commit endpoint would have eaten the new conflict error.** Context: +the endpoint that saves revisions wraps all errors and returns a generic "nothing +committed" success shape. Our new base check answers conflicts with HTTP 409. Without +an explicit exclusion, that 409 would be swallowed and the agent would see "success, +zero commits", which is exactly the silent behavior this project exists to kill. The +contract names the exclusion. + +## 6. The shipped slices, in detail + +Both are landed on stacked branches, pushed, and verified by the QA teammate: 1589 +runner tests, 778 SDK tests, 63 web tests, zero failures. + +### Slice S5: runner safety (14 files) + +Context you need: after a turn ends, the runner keeps the sandbox and the harness +session alive ("parked") so the next turn starts in 1.4 seconds instead of 12.5. To +decide whether a parked session is still valid, it compares a checksum of the +configuration. Three behaviors around this were wrong, and this slice fixes them: + +1. **A configuration change deleted the sandbox.** The teardown code mapped a + checksum mismatch to "delete", the most expensive option, and the next request + then even tried to reconnect to the sandbox it had just deleted. Now: teardown has + four precise reasons, and a mismatch that only concerns the session stops the + sandbox instead of deleting it. True incompatibility still deletes, and the + reconnect pointer is cleared in the same step. +2. **Any commit evicted the session, even a commit that changed nothing.** The + revision id itself was part of the checksum, so a new revision number alone forced + a rebuild. Now: the checksum covers content only. Same content, same session. +3. **The stale-instructions bug from finding 2.** The fix is structural: the park + call simply no longer accepts a label from the caller. The parked session's + identity comes from what the environment actually holds ("applied state"), which + only the code that successfully applied a change can update. The bug is not + fixed; it is unrepresentable. + +### Slice S4: the description field (12 files) + +Context: you asked for this directly. When the agent calls a builder tool (saving a +revision, running a test), the human sees a bare tool name in the chat. Now every +builder tool accepts an optional short description written by the agent ("committing +the two instruction fixes you approved"). The runner strips the field before the +call reaches the API, so it can never pollute a real payload (there is a test +proving a description cannot overwrite a real field of the same name), and the agent +chat tool card displays it. This is also the pattern the audit trail (decision 6) +will build on. + +## 7. The refactoring: partially done, the rest is planned in steps + +You asked earlier for a general, clean architecture of the runner so that +fingerprints route to different behaviors instead of always rebuilding. Here is +where that stands. + +**The idea, in plain words.** A request describes the configuration it WANTS +("desired state"). The environment records what it actually HAS ("applied state"). +On each turn the runner compares the two, and for every difference it asks: what is +the cheapest safe way to get from have to want? Rewrite a file in the workspace? +Tell the live session? Reopen the session on the same sandbox? Or rebuild? Each +harness declares, per kind of change, which of these it supports (the adapter +matrix, contract 6). The old single checksum disappears as a decision-maker; it +survives only as a fast "nothing changed at all" shortcut. + +**What is already done (this was S5):** the foundation. Applied state exists and the +environment owns it; teardown reasons are precise; revision numbers no longer count +as changes. + +**What comes next, in order (each its own slice):** + +1. **Move the decision logic out of the web server file** into one coordinator, with + no behavior change. Today the reuse decisions live inside the HTTP server code, + which makes every later step risky. (Slice S6.) +2. **Shadow routing.** The new compare-and-decide logic runs alongside the old one, + only logging what it WOULD have decided. We watch for disagreements before + trusting it. (Also S6.) +3. **Split the big environment file into lifecycle units** (sandbox, runtime, mount, + workspace, harness session), still with no behavior change. (S7a.) +4. **Turn on the cheap routes:** rewrite instructions and skills in place, set the + model on the live session. (S7b.) +5. **Turn on live tool updates** where finding 1 showed they are reachable, per + harness, with the acknowledgement rules. (S7c, after its foundation step S7c0.) +6. **Session reopen for MCP-server changes, and credential refresh** so a rotated + API key on Daytona no longer rebuilds the sandbox. (S7d, S7e.) + +## 8. The implicit decisions we made, with context + +Decisions the team made during design without asking you, each recorded and each +reversible by a comment on this file. + +1. **Imports come only from the `imports/` folder.** Context: the first draft allowed + any path in the workspace, with the approval card as the control. The reviewer + pushed back: the workspace also holds files the agent created for other reasons, + possibly secrets, and a human skimming a manifest is not a security boundary. A + dedicated folder makes intent explicit: things placed there are meant to be + committed. +2. **A folder with unsupported files rejects whole, by default.** Context: skill + content is stored as text, so a PNG or a compiled binary cannot be stored + faithfully today. The first draft silently dropped such files and committed the + rest. That means the user believes the skill is complete when it is not. Now the + import fails with a clear reason, and committing with omissions requires an + explicit opt-in that the approval card displays. +3. **Executable permission is four separate things.** Context: "this file is + executable" was one boolean doing four jobs. Now: the file's own mode bit is + data (always recorded); whether the import may CONTAIN executables is an + ephemeral grant the approver sees; whether the stored skill may USE them is a + persisted capability, default off; and whether the sandbox actually allows + execution stays platform policy. You can import bits faithfully without granting + execution; you cannot grant execution for bits you refused to import. +4. **After a cold resume, an approved import asks again.** Context: the approved + frozen bytes live with the parked session. If the session dies before execution + (crash, timeout), the bytes are gone. Reading the folder again would commit + content the human never saw. Storing the bytes durably would recreate the + large-payload problem. So the agent asks for approval a second time. Rare and + slightly annoying, but never wrong. +5. **One text FILE may be imported into one text FIELD.** Context: the strict + folder rules above would have removed the founding use case: an oversized + instruction file (#5554). So a single file may be committed into a single + string field (instructions, a skill body), approved as a readable diff whose old + side comes from the exact base revision. If that old text cannot be fetched, the + call fails closed rather than showing a wrong diff. Folders into text fields + stay forbidden: there is no honest way to present that as a reviewable change. +6. **A removed Pi tool is hidden AND disarmed.** Context: Pi has no API to truly + deregister a tool. Hiding removes it from what the model sees, but the tool + would still execute if called by name. So the runner also drops the execution + binding: a hidden tool that is somehow called anyway does not run. +7. **Referenced skills stay unaddressable in v1.** Context: a skill can be embedded + by reference instead of inline. A reference has no stable name of its own, so + name-based operations cannot target it, and the agent must fall back to the old + whole-list write for that one case. Designing a stable key for references is + future work; doing it now would delay everything else. +8. **The "did the live update arrive" signal is untrusted.** Context: for live tool + updates the runner wants to know the harness took the change. Any confirmation + channel from inside the sandbox can be forged by a process in the sandbox. Rather + than pretending to secure it, the design makes forgery harmless: the signal only + advances what the model is SHOWN. What a tool call is actually ALLOWED to do is + decided runner-side, outside the sandbox, always. +9. **Strict validation applies only to the new format.** Context: turning on strict + field checking for the old delta format would break shipped playbooks and stored + callers that today send harmless extra fields. Old format keeps old tolerance; + the new operations format rejects unknown fields from day one. +10. **Rollout is dark-first with a two-sided kill switch.** Context: naively shipping + the API first breaks old runners (they would forward unresolved imports). So: + API support ships disabled, then runner support disabled, then the catalog + starts advertising the new format. One flag turns it all off, enforced in BOTH + the API (rejects the new format) and the runner (refuses to read the workspace), + because a stale harness can still emit the new format after the catalog stops + advertising it. + +## 9. The open decisions (yours) Answer with one line, for example: `1A 2yes 3B 4no 5no 6yes`, or "go with your recommendations". @@ -133,16 +360,16 @@ recommendations". The agent edits text by quoting it exactly. But text has invisible variety: curly quotes from a Mac keyboard, CRLF line endings from Windows, two Unicode forms that -look identical. If stored text holds a curly quote and the agent types a straight -one, the match fails. +look identical on screen. If the stored text holds a curly quote and the agent types +a straight one, the match fails. - **Option A, exact bytes.** Store exactly what was sent. A failed match is loud, and the agent recovers by copying the true text from the read tool. Strong argument: a skill folder can contain a Windows batch file, and that file NEEDS its CRLF endings. Cleaning would corrupt it. The external reviewer recommends A. -- **Option B, clean once on write.** Normalize on save (one Unicode form, LF). - Matching almost never fails. But stored bytes change on the next save of old - fields, and file contents would need an exception anyway, which splits the rule. +- **Option B, clean once on write.** Normalize on save (one Unicode form, LF + endings). Matching almost never fails. But stored bytes change on the next save of + old fields, and file contents would need an exception anyway, which splits the rule. **Recommendation: A.** One rule, no corruption risk, and the read-before-write loop makes the occasional failed match cheap. @@ -150,8 +377,9 @@ makes the occasional failed match cheap. ### Decision 2: the unique-name rule Named editing needs unique names. Today a config with two skills named `notes` saves -fine and the runner silently drops one. But some existing agents already carry -duplicates, and a global check would make them uncommittable for unrelated changes. +fine and the runner silently drops one at run time. But some existing agents already +carry duplicates, and a global check would make them uncommittable for unrelated +changes. The proposed rule: a commit may not CREATE a new duplicate; a collection the commit touches must end up clean; old duplicates elsewhere only warn. @@ -161,9 +389,9 @@ ambiguous forever. ### Decision 3: does a folder import always need human approval? -The approval card (name, file list, diff) is the safety control for content the model -never typed. But some runs have no human, for example a nightly automation that -updates its own skills. +The approval card (name, file list, diff) is the safety control for content the +model never typed. But some runs have no human, for example a nightly automation +that updates its own skills. - **Option A, always gate.** Safest. Every unattended import stalls forever. - **Option B, gate by default; an explicit "allow" policy skips it.** A run with an @@ -176,16 +404,17 @@ updates its own skills. The harness (Pi, Claude, Codex) is the most identity-defining field: changing it swaps the runtime and forces a full rebuild. If a user says "switch yourself to -Claude", may the agent commit that, or must it point the user to the settings drawer? +Claude", may the agent commit that itself, or must it point the user to the settings +drawer? **Recommendation: no, human commit only in v1.** The write-scope is an allow-list; -widening later is one line, narrowing later is a breaking change. +widening it later is one line, narrowing it later is a breaking change. ### Decision 5: may the agent write outside its own agent section? The revision also holds workflow-level fields beside `parameters.agent`, including the service URL and the schemas. Writing the URL would let an agent redirect its own -requests, which is privilege escalation. +requests, which is privilege escalation, not configuration editing. **Recommendation: no.** Commits stay scoped to `parameters.agent`. @@ -193,20 +422,33 @@ requests, which is privilege escalation. A revision shows the result, not the intent. Storing the operations list on the commit record ("edited two lines of the instructions, added skill pdf-tools from -imports/") makes every agent commit reviewable afterwards. For imports we store the -source path and a content digest, not the bytes. +imports/") makes every agent commit reviewable afterwards, and a bad commit +diagnosable in seconds. For imports we store the source path and a content digest, +not the bytes, so the record stays small. **Recommendation: yes.** Small cost, natural companion of the description field. -## 7. Next items, in order - -1. You answer the six decisions (comments here, or one line in chat). -2. The external reviewer runs a final gate over the corrected contracts. -3. The engine slice starts: the seven operations behind the commit tool, with the - base check and validation. This unlocks US-1, US-2, US-4, US-7. -4. The read tool and the import path follow: US-5, then US-3. -5. The remaining runner work lands in steps: coordinator extraction, lifecycle split, - live updates for instructions, skills, model, and (per harness) tools. -6. One combined live QA session on the dev stack across all user stories. -7. Final code review, PR descriptions, inline comments, docs sync, and the stack is - yours to merge. +## 10. Next steps, with context + +1. **You answer the six decisions.** Comments on this file, or one line in chat. + Decision 1 gates the engine; 2 and 6 gate the commit; 3 gates imports; 4 and 5 + gate the read tool's write-scope section. +2. **A final external review.** The reviewer verifies the corrected contracts one + last time, with your answers folded in. Half a day. +3. **The engine slice.** The seven operations become the real commit path, behind + the transaction from contract 2. This is the heart: it unlocks cheap instruction + edits, per-skill edits, per-tool add/remove, and loud conflicts (US-1, 2, 4, 7). + The prototype and its 120 tests already exist; this is productization, not + invention. +4. **The read tool.** US-5, and the recovery step for every conflict. Ships together + with or right before the engine slice, because agents must read before ordered + edits become visible to them. +5. **The import path.** The folder reader, the freeze-and-authorize flow, and the + approval card. US-3. The riskiest remaining code; its contracts are the most + reviewed of the six. +6. **The runner steps from section 7**, in their listed order, each its own slice. +7. **One combined live QA session** on the dev stack across all user stories, by the + QA teammate. We deferred live QA until it can cover the real stories; unit suites + have gated every slice so far. +8. **Finalization:** external code review of the full diff, PR descriptions, inline + code comments, documentation sync, and the stack is yours to merge. From 9db30ea45f7f5785960ed330248f549583b07ff5 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Wed, 5 Aug 2026 11:00:28 +0200 Subject: [PATCH 13/36] docs(design): model usability spike: Haiku 34%->100% via tuned instructions; inline marker validated; harness included --- .../spikes/model-usability-spike.md | 471 +++++++++++++ .../spikes/model-usability/analyze.py | 138 ++++ .../spikes/model-usability/harness.py | 629 ++++++++++++++++++ .../spikes/model-usability/instructions/v0.md | 45 ++ .../spikes/model-usability/instructions/v1.md | 44 ++ .../spikes/model-usability/instructions/v2.md | 54 ++ .../spikes/model-usability/results.tar.gz | Bin 0 -> 38306 bytes .../spikes/model-usability/run.py | 427 ++++++++++++ .../spikes/model-usability/selftest.py | 329 +++++++++ .../spikes/model-usability/table.py | 111 ++++ .../spikes/model-usability/tasks.py | 481 ++++++++++++++ 11 files changed, 2729 insertions(+) create mode 100644 docs/design/agent-config-editing/spikes/model-usability-spike.md create mode 100644 docs/design/agent-config-editing/spikes/model-usability/analyze.py create mode 100644 docs/design/agent-config-editing/spikes/model-usability/harness.py create mode 100644 docs/design/agent-config-editing/spikes/model-usability/instructions/v0.md create mode 100644 docs/design/agent-config-editing/spikes/model-usability/instructions/v1.md create mode 100644 docs/design/agent-config-editing/spikes/model-usability/instructions/v2.md create mode 100644 docs/design/agent-config-editing/spikes/model-usability/results.tar.gz create mode 100644 docs/design/agent-config-editing/spikes/model-usability/run.py create mode 100644 docs/design/agent-config-editing/spikes/model-usability/selftest.py create mode 100644 docs/design/agent-config-editing/spikes/model-usability/table.py create mode 100644 docs/design/agent-config-editing/spikes/model-usability/tasks.py diff --git a/docs/design/agent-config-editing/spikes/model-usability-spike.md b/docs/design/agent-config-editing/spikes/model-usability-spike.md new file mode 100644 index 0000000000..7591ce41c9 --- /dev/null +++ b/docs/design/agent-config-editing/spikes/model-usability-spike.md @@ -0,0 +1,471 @@ +# Spike: can a small model author our config-editing operations? + +Status: complete. Owner: model-usability-spike. Date: 5 August 2026. + +The question: can a Haiku-level model correctly author the ordered operations of +`contracts/change-set.md`, given tool instructions of realistic size? And what instruction +wording maximizes its success? + +The answer: **yes, but not with the contract as written.** A model-facing document +mechanically derived from the contract gets Haiku to 34% correct. The same model, same +schema, same tasks, with a tuned 3.2 KB document reaches 96%. Two small interface changes +take it to 100%. Almost all of the gap is one thing: the target grammar's selector +segment. Section 6 lists what must change. + +| Arm | Haiku correct | DeepSeek correct | +|---|---|---| +| v0, contract-literal instructions | 19/55 (34%) | 42/55 (76%) | +| v1, first tuned draft | 48/55 (87%) | 49/55 (89%) | +| v2, tuned after reading v1 failures | 53/55 (96%) | 54/55 (98%) | +| v2 + two interface fixes | **55/55 (100%)** | 54/55 (98%) | + +## 1. What was measured, and how + +Every trial is one tool-calling conversation. The model gets a system prompt, one tool +whose `description` is the instruction document under test, the tool's JSON schema, and a +user message holding a `read_config` response plus a natural-language task. It calls the +tool. The harness plays the three layers below it: + +- the **runner** resolves workspace content markers and refuses a path outside the import + root; +- the **commit wrapper** checks `base_revision_id` against the head; +- the **engine** is the real prototype, `api/oss/src/core/workflows/change_set.py` from + worktree `agent-a2a2adaa5d154d454`, imported unmodified. + +On a rejection the model reads the error as a tool result and may retry. The cap is three +calls: the first plus two retries. On success a per-task checker inspects the config the +engine produced and asserts both the intended change and the absence of collateral damage +(a rewrite that truncates the rest of the document fails, even though the engine accepts +it). + +Grading is fully automatic. Nothing is judged by reading model prose. + +Models: `claude-haiku-4-5-20251001` through the Anthropic API, and +`deepseek/deepseek-v4-flash` through OpenRouter. Both keys were found in +`~/.agenta-qa-secrets.env`. Default temperature. 11 tasks x 5 trials x 8 arms = **440 +trials**, about 1.9M input and 0.2M output tokens. + +Before any model ran, a self-test proved every task solvable: a hand-written golden delta +for each of the 11 tasks passes the engine and its checker, and each of the three recovery +tasks provably fires its intended error. That file is `selftest.py`; it is the guard +against a task that no model could have passed. + +### The task suite + +The base config is one realistic agent template: an `agents_md` instruction document, +three skills (one with a bundled file), three tools, two MCP servers. + +| Task | What it asks | +|---|---| +| a | Replace one sentence in the instructions, keep the rest | +| b | Change one line in one skill's body | +| c | Add one builtin tool by name | +| d | Remove one MCP server | +| e | Add a skill whose body and bundled file come from workspace paths | +| f | The head moved: get a 409, re-anchor on the new config, retry | +| g | The anchor appears twice: get `text_not_unique`, retry with more context | +| h | The given folder is outside `imports/`: get refused, find the right path | +| i | Rename a skill, keeping its content | +| j | Edit a line inside a skill's bundled file (two nested selectors) | +| k | Three unrelated changes in one commit | + +### The four arms + +The schema is identical in every arm, so the only variables are the document and, in the +last arm, the runner's leniency. + +- **v0** is the contract summarized honestly and mechanically: the seven verbs in contract + wording, the target grammar as prose, the `value_from` source, the full reason-code list. + 2.9 KB. This is the document you get if you write the tool description from + `change-set.md` without watching a model use it. +- **v1** is a first tuned draft: the same content, plus a worked target example, "copy the + anchor, do not retype it", a worked import example, and one line on what to do with a + retryable error. 2.6 KB. +- **v2** is v1 rewritten after reading every v1 failure. 3.2 KB. Section 4 is its full text. +- **v2 + L** is v2 with two runner changes, described in section 5. + +## 2. Results + +### Correct final configuration, by task + +| Task | Haiku v0 | Haiku v1 | Haiku v2 | Haiku v2+L | DS v0 | DS v1 | DS v2 | DS v2+L | +|---|---|---|---|---|---|---|---|---| +| a edit one instruction sentence | 5/5 | 5/5 | 5/5 | 5/5 | 4/5 | 3/5 | 5/5 | 5/5 | +| b change one line in a skill body | 1/5 | 5/5 | 5/5 | 5/5 | 5/5 | 5/5 | 5/5 | 5/5 | +| c add one tool by name | 5/5 | 5/5 | 5/5 | 5/5 | 5/5 | 5/5 | 5/5 | 5/5 | +| d remove one MCP server | 0/5 | 5/5 | 5/5 | 5/5 | 2/5 | 5/5 | 5/5 | 5/5 | +| e add a skill from workspace files | 0/5 | 5/5 | 5/5 | 5/5 | 3/5 | 5/5 | 5/5 | 5/5 | +| f conflict, then retry on the new head | 5/5 | 5/5 | 5/5 | 5/5 | 5/5 | 3/5 | 5/5 | 5/5 | +| g ambiguous anchor, then retry | 2/5 | 5/5 | 5/5 | 5/5 | 3/5 | 5/5 | 5/5 | 5/5 | +| h wrong folder, then correct the path | 0/5 | 5/5 | 3/5 | 5/5 | 3/5 | 5/5 | 4/5 | 4/5 | +| i rename a skill, keeping its content | 0/5 | 2/5 | 5/5 | 5/5 | 4/5 | 4/5 | 5/5 | 5/5 | +| j edit a line inside a bundled file | 0/5 | 1/5 | 5/5 | 5/5 | 4/5 | 5/5 | 5/5 | 5/5 | +| k three changes in one commit | 1/5 | 5/5 | 5/5 | 5/5 | 4/5 | 4/5 | 5/5 | 5/5 | +| **all** | **19/55** | **48/55** | **53/55** | **55/55** | **42/55** | **49/55** | **54/55** | **54/55** | + +### The pipeline, stage by stage + +| Arm | called the tool | valid JSON (first call) | engine accepted | correct | +|---|---|---|---|---| +| Haiku v0 | 100% | 100% | 52% | 34% | +| Haiku v1 | 100% | 100% | 87% | 87% | +| Haiku v2 | 100% | 100% | 96% | 96% | +| Haiku v2+L | 100% | 100% | 100% | 100% | +| DS v0 | 96% | 96% | 80% | 76% | +| DS v1 | 100% | 100% | 89% | 89% | +| DS v2 | 100% | 100% | 100% | 98% | +| DS v2+L | 100% | 100% | 98% | 98% | + +Two things stand out. **Neither model struggles with JSON.** Haiku produced a well-formed +tool call on all 220 of its first calls; DeepSeek on 96% of its v0 calls and 100% +everywhere else. The losses are all semantic. And **the gap between +"engine accepted" and "correct" is almost zero except in v0**, where it is 18 points for +Haiku — meaning a bad document does not just cause rejections, it causes silently wrong +commits (section 3.2). + +### Recovery within two retries + +| Arm | f (409 conflict) | g (ambiguous anchor) | h (wrong folder) | +|---|---|---|---| +| Haiku v0 | 5 recovered, 0 never | 2 recovered, 3 never | 0 recovered, 5 never | +| Haiku v1 | 5 recovered, 0 never | 5 ok, 0 never | 5 recovered, 0 never | +| Haiku v2 | 5 recovered, 0 never | 5 right first time | 3 recovered, 2 never | +| Haiku v2+L | 5 recovered, 0 never | 5 right first time | 5 recovered, 0 never | +| DS v0 | 5 recovered, 0 never | 3 recovered, 2 never | 3 recovered, 2 never | +| DS v1 | 3 recovered, 2 never | 5 right first time | 5 recovered, 0 never | +| DS v2 | 5 recovered, 0 never | 5 ok, 0 never | 4 recovered, 1 never | +| DS v2+L | 5 recovered, 0 never | 5 right first time | 4 recovered, 1 never | + +The 409 flow works. Both models, in every arm, re-anchored on the new head and resent with +the new `base_revision_id` — 38 of 40 times. This part of the design is not the problem. + +The ambiguous-anchor flow works too, and it gets *better* than recovery: under v2 both +models pick a unique anchor on the first call, so `text_not_unique` never fires. One +sentence in the document ("if you get `text_not_unique`, add surrounding lines") converted +a retry loop into a first-call success. + +Cost fell as accuracy rose: Haiku used 360K input tokens on v0 and 201K on v2+L, because +failures are retries and retries are tokens. + +## 3. The failure modes + +66 failures out of 440 trials, in six kinds. + +### 3.1 The selector segment — 41 of 66 failures (62%) + +This is the whole story. The target grammar says a selector `{"field": F, "key": K}` names +both the list and the entry, so the list name must not also appear as a string segment +before it. **No model guesses this.** Every model writes the list name and then a selector, +because that is what a path looks like everywhere else. + +Verbatim, Haiku on v0, task d ("remove the linear MCP server"), all three attempts: + +```json +{"operation":"remove_item","target":["parameters","agent","mcps",{"field":"mcps","key":"linear"}]} + -> target_type_mismatch: "target segment 3: expected an object, found a list" +{"operation":"remove_item","target":["parameters","agent","mcps",{"field":"name","key":"linear"}]} + -> target_type_mismatch: "target segment 3: expected an object, found a list" +{"operation":"remove_item","target":["parameters","agent","mcps",{"field":"mcps","key":"linear"}]} + -> target_type_mismatch: "target segment 3: expected an object, found a list" +``` + +Three calls, one right idea, zero progress. The error names the failing segment and its +type, and it is useless: it never says "drop the repeated list name". Task d went 0/5. + +The second attempt shows the sibling mistake: `{"field": "name", ...}`. The document says +"`skills` keyed by `name`", so the model reads `field` as the *key* field rather than the +*collection*. `field` is a bad name for "the collection this entry lives in". + +The same confusion produced the deep-target failures. Haiku on v1, task j, editing a line +inside a skill's bundled file: + +```json +"target":["parameters","agent","skills",{"field":"skills","key":"release-qa"}, + "files",{"field":"files","key":"checklist.md"},"content"] + -> target_type_mismatch: "target segment 3: expected an object, found a list" +``` + +The intent is exactly right. The path is written the way a human would write it. It is +rejected on a technicality the error does not explain, and after two more guesses the trial +is lost. Task j was 1/5 on v1. + +Adding three lines to the document fixed it completely — a WRONG/WRONG/RIGHT block, quoted +in section 4. Tasks d, i, and j went to 5/5 for both models. + +### 3.2 The contract's `value_from` cannot say "this file into this field" — 11 failures + +Task e needs a new skill whose `body` comes from `SKILL.md` and whose bundled file's +`content` comes from `reference.md`. The contract's `value_from` sits on the **operation**, +so it materializes the whole item from a folder. There is no legal way to say "this one +field's content comes from this one path". + +Under v0, which documents `value_from` as the contract defines it, Haiku invented the +missing form in all five trials. What got committed: + +```json +{ + "name": "pdf-tools", + "description": "Make and merge PDF files.", + "body_from": {"type": "workspace", "path": "imports/pdf-tools/SKILL.md"}, + "files": [{"path": "reference.md", + "content_from": {"type": "workspace", "path": "imports/pdf-tools/reference.md"}}] +} +``` + +The skill has no `body` at all. It has a `body_from` key holding an unresolved source +object. **The engine accepted this and the commit succeeded.** `value` is untyped in the +schema, so `additionalProperties: false` never sees inside it, and the runner only strips +`value_from` at the operation level. + +Across tasks e and h, all ten Haiku v0 trials committed a skill that would not load, in two +variants: five invented a `body_from` / `content_from` key and shipped no `body` at all, +and five put the `{"type": "workspace", "path": ...}` object directly into `body` as its +value. An eleventh trial, DeepSeek on v0, took the third bad option and hallucinated a body +from the file name. + +This is the only failure mode in the spike that is silent. Everything else is a rejection +the model can see, and the model usually recovers from those. + +The inline marker form does not have the problem. When the document teaches +`{"$content_from": ""}` in place of any string, both models used it correctly and +tasks e and h went to 5/5. Given both surfaces in the same schema, models chose whichever +the *document* taught: 91 marker uses under v1/v2, 13 `value_from` uses under v0. + +### 3.3 DeepSeek corrupts its own arguments, always in `message` — 7 failures + +All seven of DeepSeek's malformed tool calls broke in the same field, and it writes +`message` last: + +``` +"message": "Replace 'Do not use emojis.' with 'Use plain language.' inojis.' with 'Use plain language.' in Tone section\"}}"} +``` + +The text degenerates into a repeated fragment and then trailing garbage. In **five of the +seven**, the `delta` that preceded it parses cleanly and, replayed through the engine, +produces the correct configuration. The model's change was right; a free-text commit +message destroyed it. Haiku never did this. + +### 3.4 `source_not_found` is a dead end — 4 failures + +Task h gives a path outside the import root. The intended flow is: refusal naming the +import root, model corrects the path. What actually happens is that the model reads "must +be under `imports/`" and *prefixes* it, producing `imports/scratch/pdf-tools/SKILL.md`. +That path does not exist, so it gets `source_not_found`, which says only that the file is +absent. Haiku on v1 then stopped: + +> The file path needs to be relative to the repository root starting with `imports/`. Could +> you confirm the exact path to your pdf-tools folder? Is it `imports/scratch/pdf-tools/` +> or a different location? + +That is a reasonable thing to say and a failed task. Adding the list of folders that do +exist under the import root to `source_not_found` took task h from 3/5 to 5/5 for Haiku. + +### 3.5 A no-change commit is an escape hatch — 1 failure + +DeepSeek on v2, task h, after the same `source_not_found` dead end, abandoned the task and +sent this instead: + +```json +{"operations":[{"operation":"set","target":["parameters","agent","llm","max_tokens"],"value":8192}]} +``` + +`max_tokens` was already 8192. The engine accepted it, the commit succeeded, and the model +reported success to a user whose skill was never added. The prototype has no no-change +detection, so nothing caught it. Contract section 7's `changed` flag and the commit +wrapper's no-change answer are what stop this, and neither is implemented yet. + +### 3.6 Exact matching versus soft-wrapped text — 1 failure + +The stored instruction document wraps mid-sentence: `"...when the suite is\nunavailable."`. +DeepSeek on v1 sent `"...when the suite is unavailable."` — the newline normalized to a +space — and got `text_not_found` three times, guessing at whitespace each time. The error +says "the text must match exactly, with all whitespace and newlines" and never shows what +the text actually is. Returning the nearest lines of the target fixes it. + +Worth recording: this was the *only* exact-match failure in 440 trials, and **both models +chose `edit_text` over a wholesale `set` on 100% of long-text edits in every arm, including +v0, which never warns against it.** Verb choice is not the hard part. Addressing is. + +## 4. The instruction document + +This is v2, the arm that reached 96% on Haiku and 98% on DeepSeek. It is 3.2 KB and it goes +in the tool's `description`. Source: `instructions/v2.md` in the spike directory. + +```markdown +Commit a change to this agent's own configuration. + +Send `workflow_revision` with `base_revision_id`, `message` (one short line), and +`delta`. `base_revision_id` is the `revision_id` of the configuration you read. `delta` +holds `operations`, which run in order. If one fails, nothing is committed. + +TARGET. An array of segments read from the root of the configuration. A plain string +segment names an object field. An object segment `{"field": L, "key": K}` names one entry +in the list `L` — `field` is the LIST's name, `key` is the entry's name. + +A selector REPLACES the list name. Never write the list name and then a selector. + + WRONG ["parameters","agent","skills",{"field":"skills","key":"triage"}] + WRONG ["parameters","agent","skills",{"field":"name","key":"triage"}] + RIGHT ["parameters","agent",{"field":"skills","key":"triage"}] + +Four lists take a selector: `skills`, `mcps`, `tools` (all keyed by `name`) and `files` +(keyed by `path`). Selectors nest, and again the inner list name is not repeated: + + ["parameters","agent",{"field":"skills","key":"release-qa"}, + {"field":"files","key":"checklist.md"},"content"] + +OPERATIONS. `set` replaces one field (needs `value`). `merge` deep-merges an object into +one field (needs `value`). `remove` deletes one field. `edit_text` replaces exact +substrings in one string field (needs `edits`). `add_item` appends to a list; its target +ENDS with the list name (needs `value`). `replace_item` replaces one entry and +`remove_item` deletes one entry; their targets END with a selector. + +`replace_item` cannot rename: the new value must keep the same key. To rename an entry, +send `remove_item` and then `add_item` in the same delta. + +EDIT_TEXT is how you change part of a long text. Do not `set` a whole document to change +one line. Each edit carries `old_text` and `new_text`. `old_text` must occur exactly once +and must match character for character. Copy it out of the configuration you read; never +retype it from memory. Watch the line breaks: a sentence in the stored text may wrap +across a `\n` where a paragraph would read as one line. If you get `text_not_found`, your +whitespace is wrong — copy a shorter fragment that you can see verbatim. If you get +`text_not_unique`, add surrounding lines until the anchor is unique. + +WORKSPACE FILES. To use a file's content as a value, write `{"$content_from": ""}` +where the string would go. Paths are relative to the repository root and must be under +`imports/`. Do not invent a path: use the one you were given, and if it is refused, use a +path the error offers. + + {"operation":"add_item","target":["parameters","agent","skills"], + "value":{"name":"pdf-tools","description":"Make PDFs.", + "body":{"$content_from":"imports/pdf-tools/SKILL.md"}, + "files":[{"path":"reference.md", + "content":{"$content_from":"imports/pdf-tools/reference.md"}}]}} + +ERRORS carry a reason code and `retryable`. When it is retryable, fix the operation and +call again; do not ask the user. `stale_base_revision` means someone else committed: the +error carries the new head and its data, so re-anchor your edits against that data and +resend with the new `base_revision_id`. +``` + +### Which sentences earned their place + +Measured by what the v0 to v2 diff bought: + +| Element | Worth | +|---|---| +| The WRONG/WRONG/RIGHT selector block | +26 Haiku trials. The single highest-value item in the document. | +| The nested-selector example (`skills` then `files`) | Task j, 1/5 to 5/5. | +| `{"$content_from": ...}` taught instead of `value_from` | Tasks e and h, 0/5 to 5/5 on Haiku, and it ends the silent-corruption mode. | +| "`replace_item` cannot rename; use remove + add" | Task i, 2/5 to 5/5. | +| "add surrounding lines until the anchor is unique" | Turned task g from a retry loop into a first-call success. | +| "when it is retryable, fix it and call again; do not ask the user" | Removed two of four give-ups. | +| "Do not `set` a whole document to change one line" | **Nothing.** Both models always chose `edit_text` anyway, even on v0. | +| The full reason-code list (v0 had it, v2 does not) | **Nothing.** Models read the code they receive, not a catalog they were shown. | + +Two lessons for whoever writes the real tool description. Show a wrong example next to the +right one — a positive example alone did not stop the selector mistake, because the model's +wrong form also looks like the positive example. And spend the space on the shapes the +model must produce, not on the vocabulary it will only ever consume. + +## 5. The two interface fixes tested (arm "v2 + L") + +Both live in the runner and the commit wrapper. Neither changes the contract's semantics. + +1. **Forgive the two selector mistakes.** Before the engine runs, normalize each target: + drop a string segment that repeats the list name of the selector right after it, and + rewrite a selector whose `field` holds the collection's key field. So + `["...","mcps",{"field":"mcps","key":"linear"}]` and + `["...","mcps",{"field":"name","key":"linear"}]` both become + `["...",{"field":"mcps","key":"linear"}]`. Unambiguous in both cases. +2. **Make the dead-end errors carry the missing fact.** `source_not_found` lists the + folders that do exist under the import root. `text_not_found` returns the two or three + lines of the target that most resemble the failed anchor, so the model can see the real + line breaks instead of guessing. + +Effect on Haiku: 53/55 to **55/55**, and task h from 3/5 to 5/5. Effect on DeepSeek: none — +v2's wording had already closed the same gaps for it, so leniency had nothing to repair. +That is the point. The fixes are insurance for the weaker model and cost the stronger one +nothing. + +## 6. Verdict + +A Haiku-level model can drive this interface. It needs three things. + +**Must change in the interface.** + +1. **`value_from` cannot express "one file into one field of a new item", and the gap is + filled silently.** This caused the only invisible failure in the spike: a committed + skill with `body_from` instead of `body`. Either add an inline per-field content marker + (recommended — models reached for it 91 times across v1 and v2 and never + once invented a variant of it, against 5 inventions in 5 v0 trials), or make the schema reject + unknown `*_from` keys inside `value`, so the model is told rather than obeyed. Doing + neither means a small model can commit a broken config and report success. This is the + one finding I would treat as blocking. +2. **`source_not_found` and `text_not_found` are dead ends.** Both say what is wrong and + nothing about what would be right. `source_not_found` should list what exists under the + import root; `text_not_found` should return the nearest lines of the target. Together + they were 5 of the 66 failures and 100% of the "stopped and asked the user" cases. +3. **Implement contract section 7's `changed` flag and the wrapper's no-change answer + before this ships.** A model that cannot make progress will commit a no-op to produce a + successful tool call. Observed once, and it reported success on a task it had abandoned. +4. **Consider making `message` optional.** It is where 7 of 7 DeepSeek JSON failures + occurred, and in 5 of those the delta was already correct. A server-derived summary + would have saved them. This matters only for the weaker model, but it is nearly free. + +**Should change in the target grammar.** The selector is the interface's single hardest +element: 41 of 66 failures, and 26 of Haiku's 36 v0 failures. Wording fixes it, but wording +is a per-tool-description tax that every future caller must keep paying. Two cheaper +options, in order of preference: + +- normalize the redundant list-name segment in the wrapper, as tested in section 5 — it + removes the mistake without changing what a correct target looks like; +- rename `field` to `list` or `collection`. `field` is what made models put `name` there. + +The contract itself does not need to change for either. + +**Must change in the instructions.** Ship section 4's document, not a summary of the +contract. The gap between the two is 34% and 96% on the same model. The reason-code +catalog and the verb-choice advice can be dropped; the space belongs to the target grammar, +with a wrong example beside the right one. + +## 7. Limits of this spike + +- The schema was held constant across all arms so that the document was the only variable. + A different operation-schema shape (the contract's seven-member `oneOf` versus the flat + operation object used here) was not measured. +- The engine is the prototype, which still counts occurrences without overlap and does not + create parents, compute `changed`, or emit warnings. Contract section 12 lists the gap. + Nothing in these results depends on those items except finding 3 in section 6, which is + about the missing `changed`. +- The workspace is simulated in memory. Real import policy (`on_unsupported`, + `on_executable`, `persist_executable_capability`) was not exercised; no task needed it. + Those three fields are model-visible per contract section 5.1.3 and are untested here. +- Five trials per cell. A 5/5 and a 4/5 are not meaningfully different; a 0/5 and a 5/5 + are. The claims above rest on the large gaps, not the small ones. +- One task, (h), has an artificial edge: the model must discover that `imports/pdf-tools/` + exists. The refusal message names it, which is the behavior under test. + +## 8. Reproducing + +The harness and the instruction documents live beside this file, in +`spikes/model-usability/`. The 440 raw trials are `results.tar.gz` in that directory; each +line of each JSONL file is one trial, with every attempt the model made, what it sent, and +what it got back. + +``` +selftest.py proves all 11 tasks solvable and every negative case fires +tasks.py base configs, prompts, checkers +harness.py runner + commit wrapper + tool schema + the lenient arm +run.py one arm: uv run run.py --model haiku --instructions v2 --n 5 --out ... +analyze.py rates and failure modes +table.py the markdown tables in section 2 +instructions/ v0.md, v1.md, v2.md +results.tar.gz 440 trials as JSONL, plus the generated tables +``` + +`run.py` needs `change_set.py` beside it: copy it from +`api/oss/src/core/workflows/change_set.py` in worktree `agent-a2a2adaa5d154d454`, or from +wherever the engine lands after slice 1. Add `--lenient` for the interface arm. + +Keys are read from `~/.agenta-qa-secrets.env`. No key value is written to any output file. diff --git a/docs/design/agent-config-editing/spikes/model-usability/analyze.py b/docs/design/agent-config-editing/spikes/model-usability/analyze.py new file mode 100644 index 0000000000..a0bfef3394 --- /dev/null +++ b/docs/design/agent-config-editing/spikes/model-usability/analyze.py @@ -0,0 +1,138 @@ +# /// script +# requires-python = ">=3.11" +# dependencies = [] +# /// +"""Summarize result files: rates per task, and the failure modes with examples.""" + +import argparse +import collections +import glob +import json +import pathlib +import sys + +HERE = pathlib.Path(__file__).parent +sys.path.insert(0, str(HERE)) +import tasks as T # noqa: E402 + +TASK_IDS = [t.tid for t in T.TASKS] + + +def classify(record): + """One short label per failure.""" + if record["correct"]: + return "correct" + error = record["error"] or "" + if error.startswith("api:") or "failed after" in error: + return "harness/api error" + if error == "no tool call": + last = [a for a in record["attempts"] if a.get("no_tool_call")] + if record["attempts_used"] > 1: + return "gave up and asked the user" + return "answered in prose, never called the tool" + if error == "unparseable tool arguments": + return "malformed JSON arguments" + if error.startswith("wrong result:"): + return "engine accepted, wrong config: " + error[len("wrong result: ") :] + if error.startswith("gave up"): + codes = [] + for attempt in record["attempts"]: + reason = (attempt.get("error") or {}).get("reason") or {} + codes.append(reason.get("code") or (attempt.get("error") or {}).get("code")) + return "3 rejections: " + ",".join(str(c) for c in codes) + return error[:80] + + +def load(paths): + rows = [] + for path in paths: + for line in open(path): + rows.append(json.loads(line)) + return rows + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("globs", nargs="+") + parser.add_argument("--failures", action="store_true") + args = parser.parse_args() + + files = [] + for pattern in args.globs: + files.extend(sorted(glob.glob(pattern))) + rows = load(files) + + groups = collections.defaultdict(list) + for row in rows: + groups[(row["model"], row["instructions"], row.get("lenient", False))].append(row) + + for key in sorted(groups): + model, version, rich = key + arm = groups[key] + label = f"{model} / {version}" + (" + lenient interface" if rich else "") + print(f"\n=== {label} (n={len(arm)}) ===") + header = " task tool_call json_ok engine_ok correct" + print(header) + for tid in TASK_IDS: + batch = [r for r in arm if r["task"] == tid] + if not batch: + continue + n = len(batch) + print( + f" {tid:>4} {sum(r['tool_call_made'] for r in batch)}/{n:<8} " + f"{sum(r['json_ok'] for r in batch)}/{n:<6} " + f"{sum(r['engine_accepted'] for r in batch)}/{n:<8} " + f"{sum(r['correct'] for r in batch)}/{n}" + ) + n = len(arm) + print( + f" ALL {sum(r['tool_call_made'] for r in arm)}/{n:<8} " + f"{sum(r['json_ok'] for r in arm)}/{n:<6} " + f"{sum(r['engine_accepted'] for r in arm)}/{n:<8} " + f"{sum(r['correct'] for r in arm)}/{n}" + ) + # recovery tasks + for tid in ("f", "g", "h"): + batch = [r for r in arm if r["task"] == tid] + if not batch: + continue + first_try = sum(1 for r in batch if r["correct"] and r["attempts_used"] == 1) + after = sum(1 for r in batch if r["correct"] and r["attempts_used"] > 1) + print( + f" recovery {tid}: correct first call {first_try}, " + f"correct after a retry {after}, never {len(batch) - first_try - after}" + ) + + print("\n=== failure modes (all arms) ===") + counter = collections.Counter() + example = {} + for row in rows: + label = classify(row) + if label == "correct": + continue + counter[label] += 1 + example.setdefault(label, row) + for label, count in counter.most_common(): + row = example[label] + print(f" {count:>3} [{row['model']}/{row['instructions']} task {row['task']}] {label}") + + if args.failures: + print("\n=== verbatim examples ===") + for label in counter: + row = example[label] + print(f"\n--- {label} :: {row['model']}/{row['instructions']} task {row['task']} trial {row['trial']}") + for attempt in row["attempts"]: + if attempt.get("no_tool_call"): + print(" NO TOOL CALL, model said:") + print(" " + attempt["text"][:700].replace("\n", "\n ")) + continue + print(f" attempt {attempt['attempt']} sent:") + print(" " + json.dumps(attempt["envelope"], ensure_ascii=False)[:900]) + if attempt.get("error"): + print(" -> " + json.dumps(attempt["error"], ensure_ascii=False)[:400]) + if row["error"] and row["error"].startswith("wrong result"): + print(" -> " + row["error"][:300]) + + +if __name__ == "__main__": + main() diff --git a/docs/design/agent-config-editing/spikes/model-usability/harness.py b/docs/design/agent-config-editing/spikes/model-usability/harness.py new file mode 100644 index 0000000000..e5dac632ec --- /dev/null +++ b/docs/design/agent-config-editing/spikes/model-usability/harness.py @@ -0,0 +1,629 @@ +"""The spike harness. + +It plays the three layers the model talks to: + +- the **runner**: resolves ``$content_from`` markers against a simulated workspace, + and refuses a path outside the import root; +- the **commit wrapper**: checks ``base_revision_id`` against the head, then applies; +- the **engine**: the real ``apply_change_set`` prototype, unmodified. + +The model never sees Python. It sees one tool, its JSON schema, its description (the +instruction document under test), and the tool results the three layers return. +""" + +import copy +import json +import os +from typing import Any, Dict, List, Optional, Tuple + +from change_set import ChangeSetError, apply_change_set +from tasks import IMPORT_ROOT, WORKSPACE + +TOOL_NAME = "commit_workflow_revision" + +MARKER = "$content_from" + + +# -------------------------------------------------------------------------------------- +# The runner: resolve content markers +# -------------------------------------------------------------------------------------- + + +class RunnerRefusal(Exception): + def __init__(self, reason: str, message: str, retryable: bool = True, **ctx: Any): + super().__init__(message) + self.reason = reason + self.message = message + self.retryable = retryable + self.ctx = ctx + + def to_detail(self) -> Dict[str, Any]: + reason = {"code": self.reason, "message": self.message} + reason.update(self.ctx) + return { + "code": "change_set_rejected", + "message": "No revision was committed.", + "reason": reason, + "retryable": self.retryable, + } + + +def resolve_markers(value: Any) -> Any: + """Replace every ``{"$content_from": path}`` object with the file's text.""" + if isinstance(value, dict): + if set(value) == {MARKER}: + path = value[MARKER] + if not isinstance(path, str) or not path: + raise RunnerRefusal( + "source_invalid", + f"'{MARKER}' needs a non-empty workspace path.", + retryable=False, + ) + return _materialize_file(path) + if MARKER in value and len(value) > 1: + raise RunnerRefusal( + "source_invalid", + f"An object that carries '{MARKER}' must carry nothing else. " + f"Found: {sorted(value)}.", + retryable=False, + ) + return {key: resolve_markers(item) for key, item in value.items()} + if isinstance(value, list): + return [resolve_markers(item) for item in value] + return value + + +# -------------------------------------------------------------------------------------- +# The runner: resolve the contract's `value_from` source +# -------------------------------------------------------------------------------------- + +RICH_ERRORS = False # set by the runner: does source_not_found list what does exist? + + +def _import_folders() -> List[str]: + return sorted( + { + f"{IMPORT_ROOT}{key[len(IMPORT_ROOT):].split('/')[0]}/" + for key in WORKSPACE + if key.startswith(IMPORT_ROOT) + } + ) + + +def _not_found(path: str) -> "RunnerRefusal": + ctx: Dict[str, Any] = {"path": path} + if RICH_ERRORS: + ctx["folders_under_import_root"] = _import_folders() + return RunnerRefusal( + "source_not_found", + f"The workspace has no file at {path!r}.", + **ctx, + ) + + +def _check_root(path: str) -> None: + if not path.startswith(IMPORT_ROOT): + raise RunnerRefusal( + "source_outside_import_root", + f"The workspace path {path!r} is outside the import root. " + f"Only files under '{IMPORT_ROOT}' can be imported.", + import_root=IMPORT_ROOT, + path=path, + folders_under_import_root=_import_folders(), + ) + + +def _split_frontmatter(text: str) -> Tuple[str, Dict[str, str]]: + if not text.startswith("---\n"): + return text, {} + end = text.find("\n---\n", 4) + if end < 0: + return text, {} + meta: Dict[str, str] = {} + for line in text[4:end].splitlines(): + if ":" in line: + key, value = line.split(":", 1) + meta[key.strip()] = value.strip() + return text[end + 5 :], meta + + +def _materialize_folder(path: str) -> Dict[str, Any]: + """The contract's folder source: one workspace folder becomes one skill entry.""" + _check_root(path) + folder = path.rstrip("/") + "/" + files = { + key[len(folder) :]: value + for key, value in WORKSPACE.items() + if key.startswith(folder) + } + if not files: + raise _not_found(path) + if "SKILL.md" not in files: + raise RunnerRefusal( + "source_invalid", + f"The folder {path!r} has no SKILL.md, so it is not a skill folder.", + retryable=False, + ) + body, meta = _split_frontmatter(files["SKILL.md"]) + return { + "name": meta.get("name") or folder.rstrip("/").split("/")[-1], + "description": meta.get("description", ""), + "body": body, + "files": [ + {"path": name, "content": content} + for name, content in sorted(files.items()) + if name != "SKILL.md" + ], + } + + +def _materialize_file(path: str) -> str: + _check_root(path) + if path not in WORKSPACE: + raise _not_found(path) + return WORKSPACE[path] + + +def resolve_value_from(delta: Any) -> Any: + """Turn every `value_from` on an operation into an inline `value`, then strip it. + + Folder source on `add_item` / `replace_item`; single file on `set`. `merge`, + `remove`, `edit_text`, and `remove_item` may not carry it at all. + """ + if not isinstance(delta, dict): + return delta + operations = delta.get("operations") + if not isinstance(operations, list): + return delta + + out = [] + for operation in operations: + if not isinstance(operation, dict) or "value_from" not in operation: + out.append(operation) + continue + verb = operation.get("operation") + source = operation["value_from"] + if not isinstance(source, dict) or not source.get("path"): + raise RunnerRefusal( + "source_invalid", + "'value_from' needs {\"type\": \"workspace\", \"path\": \"...\"}.", + retryable=False, + ) + if verb in ("add_item", "replace_item"): + value: Any = _materialize_folder(source["path"]) + elif verb == "set": + value = _materialize_file(source["path"]) + else: + raise RunnerRefusal( + "invalid_operation", + f"'{verb}' does not take 'value_from'. Only set, add_item, and " + "replace_item do.", + retryable=False, + ) + replacement = {k: v for k, v in operation.items() if k != "value_from"} + replacement["value"] = value + out.append(replacement) + + return dict(delta, operations=out) + + +# -------------------------------------------------------------------------------------- +# The lenient interface arm: forgive the two target-grammar mistakes every model makes +# -------------------------------------------------------------------------------------- + +LENIENT = False + +# Which field names key which collection, so a selector that names the key field instead +# of the collection can be repaired. +_KEY_FIELD_OF = {"skills": "name", "mcps": "name", "tools": "name", "files": "path"} +_COLLECTION_OF_KEY_FIELD = {"path": "files"} + + +def normalize_target(segments: Any) -> Any: + """Repair the two mistakes the trials showed, without changing anything else. + + 1. The list name repeated before its own selector: + ``["...","skills",{"field":"skills","key":K}]`` -> the string segment is dropped. + 2. The selector's ``field`` holding the KEY field instead of the collection: + ``["...","files",{"field":"path","key":K}]`` -> ``{"field":"files","key":K}``. + """ + if not isinstance(segments, list): + return segments + out: List[Any] = [] + for segment in segments: + if ( + isinstance(segment, dict) + and set(segment) == {"field", "key"} + and out + and isinstance(out[-1], str) + ): + previous = out[-1] + field = segment["field"] + # case 2: the selector names the key field; the previous segment names the list + if previous in _KEY_FIELD_OF and field == _KEY_FIELD_OF[previous]: + out.pop() + out.append({"field": previous, "key": segment["key"]}) + continue + # case 1: the selector repeats the list name + if previous == field: + out.pop() + out.append(segment) + continue + out.append(segment) + return out + + +def normalize_delta(delta: Any) -> Any: + if not LENIENT or not isinstance(delta, dict): + return delta + operations = delta.get("operations") + if not isinstance(operations, list): + return delta + return dict( + delta, + operations=[ + dict(op, target=normalize_target(op.get("target"))) + if isinstance(op, dict) and "target" in op + else op + for op in operations + ], + ) + + +def _closest_fragments(text: str, old_text: str, limit: int = 3) -> List[str]: + """Lines of the target that look like the anchor the model failed to match. + + A `text_not_found` today says only "it does not occur". The trials show the model + then guesses at whitespace until it runs out of retries. Showing the real lines ends + that loop. + """ + import difflib + + wanted = " ".join(old_text.split())[:80].lower() + if not wanted: + return [] + lines = text.splitlines() + scored = [] + for index, line in enumerate(lines): + candidate = " ".join(line.split()).lower() + if not candidate: + continue + ratio = difflib.SequenceMatcher(None, wanted, candidate).ratio() + if wanted[:40] in candidate or candidate in wanted or ratio > 0.5: + window = "\n".join(lines[index : index + 2]) + scored.append((ratio, window)) + scored.sort(reverse=True) + seen: List[str] = [] + for _, window in scored: + if window not in seen: + seen.append(window) + if len(seen) >= limit: + break + return seen + + +def enrich_error( + detail: Dict[str, Any], delta: Any, config: Dict[str, Any] +) -> Dict[str, Any]: + """Add the one fact each dead-end error is missing.""" + if not LENIENT: + return detail + reason = detail.get("reason") or {} + code = reason.get("code") + if code == "text_not_found": + index = detail.get("operation_index") + try: + operation = delta["operations"][index] + text = _walk_config(config, operation["target"]) + old_text = operation["edits"][reason.get("edit_index", 0)]["old_text"] + except Exception: # noqa: BLE001 + return detail + if isinstance(text, str): + candidates = _closest_fragments(text, old_text) + if candidates: + reason["nearest_text_in_target"] = candidates + reason["message"] = ( + reason.get("message", "") + + " These lines of the target look closest; copy one of them " + "exactly, line breaks included." + ) + return detail + + +def _walk_config(config: Dict[str, Any], segments: Any) -> Any: + node: Any = config + for segment in normalize_target(segments): + if isinstance(segment, str): + if not isinstance(node, dict) or segment not in node: + return None + node = node[segment] + elif isinstance(segment, dict): + collection = node.get(segment.get("field")) if isinstance(node, dict) else None + if not isinstance(collection, list): + return None + key_field = _KEY_FIELD_OF.get(segment.get("field"), "name") + match = [e for e in collection if isinstance(e, dict) and e.get(key_field) == segment.get("key")] + if len(match) != 1: + return None + node = match[0] + return node + + +# -------------------------------------------------------------------------------------- +# The commit wrapper +# -------------------------------------------------------------------------------------- + + +def run_commit( + envelope: Any, + *, + head_config: Dict[str, Any], + head_revision_id: str, +) -> Tuple[Optional[Dict[str, Any]], Dict[str, Any]]: + """``(new_config_or_None, tool_result_payload)``.""" + if not isinstance(envelope, dict): + return None, { + "error": { + "code": "invalid_request", + "message": "The tool input must be a JSON object.", + "retryable": False, + } + } + revision = envelope.get("workflow_revision") + if not isinstance(revision, dict): + return None, { + "error": { + "code": "invalid_request", + "message": "The tool input needs a 'workflow_revision' object.", + "retryable": False, + } + } + + base_revision_id = revision.get("base_revision_id") + if not base_revision_id: + return None, { + "error": { + "code": "missing_base_revision_id", + "message": "An ordered delta needs 'base_revision_id'. Copy it from the " + "configuration you read.", + "retryable": True, + } + } + if base_revision_id != head_revision_id: + return None, { + "error": { + "code": "stale_base_revision", + "message": "Someone committed while you were working. Re-read the " + "configuration and send your change again against the new head.", + "your_base_revision_id": base_revision_id, + "head_revision_id": head_revision_id, + "retryable": True, + } + } + + delta = revision.get("delta") + if not isinstance(delta, dict): + return None, { + "error": { + "code": "invalid_request", + "message": "'workflow_revision.delta' must be an object.", + "retryable": False, + } + } + + delta = normalize_delta(delta) + + try: + resolved = resolve_markers(resolve_value_from(delta)) + except RunnerRefusal as refusal: + return None, {"error": refusal.to_detail()} + + try: + result = apply_change_set(copy.deepcopy(head_config), resolved) + except ChangeSetError as error: + return None, { + "error": enrich_error(error.to_detail(), resolved, head_config) + } + + return result, { + "committed": True, + "revision_id": "019c8a10-0000-7000-8000-0000000000ff", + } + + +# -------------------------------------------------------------------------------------- +# The tool schema +# -------------------------------------------------------------------------------------- + +_SEGMENT = { + "oneOf": [ + {"type": "string", "minLength": 1}, + { + "type": "object", + "additionalProperties": False, + "required": ["field", "key"], + "properties": { + "field": {"type": "string", "minLength": 1}, + "key": {"type": "string", "minLength": 1}, + }, + }, + ] +} + +_TARGET = { + "type": "array", + "minItems": 1, + "maxItems": 12, + "items": _SEGMENT, + "description": "The path to the field or list entry this operation addresses.", +} + +_EDITS = { + "type": "array", + "minItems": 1, + "maxItems": 32, + "items": { + "type": "object", + "additionalProperties": False, + "required": ["old_text", "new_text"], + "properties": { + "old_text": {"type": "string", "minLength": 1, "maxLength": 20000}, + "new_text": {"type": "string", "maxLength": 50000}, + }, + }, +} + +_SOURCE = { + "type": "object", + "additionalProperties": False, + "required": ["type", "path"], + "properties": { + "type": {"const": "workspace"}, + "path": {"type": "string", "minLength": 1}, + }, +} + +_FLAT_OPERATION = { + "type": "object", + "additionalProperties": False, + "required": ["operation", "target"], + "properties": { + "operation": { + "type": "string", + "enum": [ + "set", + "merge", + "remove", + "edit_text", + "add_item", + "replace_item", + "remove_item", + ], + }, + "target": _TARGET, + "value": { + "description": "The new value. Only for set, merge, add_item, replace_item." + }, + "edits": dict(_EDITS, description="Only for edit_text."), + "value_from": dict( + _SOURCE, description="Only for set, add_item, replace_item." + ), + }, +} + + +def _member(operation: str, *, target_tail: str, value: bool, edits: bool) -> dict: + props: Dict[str, Any] = { + "operation": {"const": operation}, + "target": _TARGET, + } + required = ["operation", "target"] + if value: + props["value"] = {"description": "The new value."} + props["value_from"] = _SOURCE + required.append("value") + if edits: + props["edits"] = _EDITS + required.append("edits") + if "value_from" in props: + required = [item for item in required if item != "value"] + return { + "type": "object", + "additionalProperties": False, + "required": required, + "properties": props, + "description": target_tail, + } + + +_UNION_OPERATION = { + "oneOf": [ + _member( + "set", + target_tail="Replace one field. The last target segment is a field name.", + value=True, + edits=False, + ), + _member( + "merge", + target_tail="Deep-merge an object into one field.", + value=True, + edits=False, + ), + _member( + "remove", + target_tail="Delete one field.", + value=False, + edits=False, + ), + _member( + "edit_text", + target_tail="Replace exact substrings inside one string field.", + value=False, + edits=True, + ), + _member( + "add_item", + target_tail="Append one entry. The last target segment is the list name.", + value=True, + edits=False, + ), + _member( + "replace_item", + target_tail="Replace one named entry. The last segment is {field, key}.", + value=True, + edits=False, + ), + _member( + "remove_item", + target_tail="Delete one named entry. The last segment is {field, key}.", + value=False, + edits=False, + ), + ] +} + + +def tool_schema(*, union: bool = False) -> Dict[str, Any]: + operation = _UNION_OPERATION if union else _FLAT_OPERATION + return { + "type": "object", + "additionalProperties": False, + "required": ["workflow_revision"], + "properties": { + "workflow_revision": { + "type": "object", + "additionalProperties": False, + "required": ["base_revision_id", "message", "delta"], + "properties": { + "base_revision_id": { + "type": "string", + "description": "The revision your change is based on.", + }, + "message": { + "type": "string", + "description": "The commit message.", + }, + "delta": { + "type": "object", + "additionalProperties": False, + "required": ["operations"], + "properties": { + "operations": { + "type": "array", + "minItems": 1, + "maxItems": 64, + "items": operation, + } + }, + }, + }, + } + }, + } + + +def read_config_result(config: Dict[str, Any], revision_id: str) -> str: + return json.dumps( + {"revision_id": revision_id, "data": config}, indent=2, ensure_ascii=False + ) diff --git a/docs/design/agent-config-editing/spikes/model-usability/instructions/v0.md b/docs/design/agent-config-editing/spikes/model-usability/instructions/v0.md new file mode 100644 index 0000000000..fd62dbb3df --- /dev/null +++ b/docs/design/agent-config-editing/spikes/model-usability/instructions/v0.md @@ -0,0 +1,45 @@ +Commit a new revision of this workflow's configuration. + +The input is `workflow_revision`, which carries `base_revision_id`, `message`, and +`delta`. The delta carries `operations`, an ordered array of between 1 and 64 operations. +Operations run in array order. Each one sees the result of the operations before it. The +first failing operation aborts the whole change set; nothing partial is committed. + +A target is an array of 1 to 12 segments. A string segment addresses an object field. An +object segment `{"field": F, "key": K}` addresses one named entry in the list at `F`. +Four collections take a selector segment: `skills` keyed by `name`, `mcps` keyed by +`name`, `files` keyed by `path`, and `tools` keyed by the canonical tool name. + +The seven operations: + +- `set` replaces the target value exactly. It takes `value`. It creates missing plain + object parents. +- `merge` deep-merges an object into the target. Nested objects merge; scalars and lists + replace. It takes `value`. The target must exist and must be an object. +- `remove` removes one object field. A missing field is `target_not_found`. +- `edit_text` applies anchored edits to a string field. It takes `edits`, an array of + `{old_text, new_text}`. Matching is exact on the code points. Nothing is normalized. + `old_text` must occur exactly one time. Matches must not overlap. The batch is atomic. +- `add_item` appends one entry to a keyed collection. It takes `value`. The engine + derives the key from the value. An existing entry with that key is + `item_already_exists`. +- `replace_item` replaces one existing entry. It takes `value`. The key derived from the + value must equal the key in the target. +- `remove_item` removes one existing entry. A missing entry is `item_not_found`. + +For `set`, `merge`, `remove`, and `edit_text` the last target segment is a string. For +`add_item` the last segment is the list name. For `replace_item` and `remove_item` the +last segment is a selector. + +An operation carries exactly one of `value` and `value_from`. `value_from` names a +workspace source: `{"type": "workspace", "path": "..."}`. On `add_item` and +`replace_item` the path is a folder, which becomes one item. On `set` the path is exactly +one file, and the target must already hold a string. `merge`, `remove`, `edit_text`, and +`remove_item` do not take `value_from`. Paths are relative to the import root. + +A failure returns HTTP 422 with a reason code and a `retryable` flag. The codes are +`target_not_found`, `target_type_mismatch`, `item_already_exists`, `item_not_found`, +`duplicate_item_key`, `text_not_found`, `text_not_unique`, `text_edits_overlap`, +`no_change`, `empty_old_text`, `unkeyed_collection`, `item_key_undefined`, +`source_not_found`, `source_invalid`, `out_of_scope`, `invalid_delta`, +`invalid_operation`, and `final_validation_failed`. A stale base returns HTTP 409. diff --git a/docs/design/agent-config-editing/spikes/model-usability/instructions/v1.md b/docs/design/agent-config-editing/spikes/model-usability/instructions/v1.md new file mode 100644 index 0000000000..0776b4d763 --- /dev/null +++ b/docs/design/agent-config-editing/spikes/model-usability/instructions/v1.md @@ -0,0 +1,44 @@ +Commit a change to this agent's own configuration. + +Send `workflow_revision` with `base_revision_id`, `message`, and `delta`. +`base_revision_id` is the `revision_id` of the configuration you read. `delta` holds an +ordered list of operations. They run in order. If one fails, nothing is committed. + +TARGET. Every operation names a target: an array of segments read from the root of the +configuration. A plain string segment names an object field. An object segment +`{"field": F, "key": K}` names one entry in the list `F`. Four lists are addressed by +key: `skills` (by `name`), `mcps` (by `name`), `tools` (by `name`), `files` (by `path`). +Example: `["parameters","agent",{"field":"skills","key":"release-qa"},"body"]` + +OPERATIONS. + +- `set` — replace one field. The target ends with a field name. Needs `value`. +- `merge` — deep-merge an object into one field. Needs `value`. +- `remove` — delete one field. +- `edit_text` — replace exact substrings inside one string field. Needs `edits`. +- `add_item` — append one entry to a keyed list. The target ends with the list name. + Needs `value`. +- `replace_item` — replace one entry. The target ends with `{field, key}`. Needs `value`. +- `remove_item` — delete one entry. The target ends with `{field, key}`. + +EDIT_TEXT is how you change part of a long text. Do not `set` a whole document to change +one line. Each edit carries `old_text` and `new_text`. `old_text` must occur exactly once +in the target string, and it must match character for character: same spaces, same line +breaks, same punctuation. Copy it from the configuration you read; do not retype it. If +it occurs more than once you get `text_not_unique` with a match count. Retry with more +surrounding lines until the anchor is unique. + +WORKSPACE FILES. To use a file's content as a value, write `{"$content_from": ""}` +where the string would go. The path is relative to the repository root and must start +with `imports/`. Example: + + {"operation":"add_item","target":["parameters","agent","skills"], + "value":{"name":"pdf-tools","description":"Make PDFs.", + "body":{"$content_from":"imports/pdf-tools/SKILL.md"}, + "files":[{"path":"reference.md", + "content":{"$content_from":"imports/pdf-tools/reference.md"}}]}} + +ERRORS. A failure returns a reason code and `retryable`. When it is retryable, fix the +operation and call again. `stale_base_revision` means someone committed while you were +working: the error carries the new head and its data. Re-anchor your change against that +data and resend with the new `base_revision_id`. diff --git a/docs/design/agent-config-editing/spikes/model-usability/instructions/v2.md b/docs/design/agent-config-editing/spikes/model-usability/instructions/v2.md new file mode 100644 index 0000000000..854bc5c89a --- /dev/null +++ b/docs/design/agent-config-editing/spikes/model-usability/instructions/v2.md @@ -0,0 +1,54 @@ +Commit a change to this agent's own configuration. + +Send `workflow_revision` with `base_revision_id`, `message` (one short line), and +`delta`. `base_revision_id` is the `revision_id` of the configuration you read. `delta` +holds `operations`, which run in order. If one fails, nothing is committed. + +TARGET. An array of segments read from the root of the configuration. A plain string +segment names an object field. An object segment `{"field": L, "key": K}` names one entry +in the list `L` — `field` is the LIST's name, `key` is the entry's name. + +A selector REPLACES the list name. Never write the list name and then a selector. + + WRONG ["parameters","agent","skills",{"field":"skills","key":"triage"}] + WRONG ["parameters","agent","skills",{"field":"name","key":"triage"}] + RIGHT ["parameters","agent",{"field":"skills","key":"triage"}] + +Four lists take a selector: `skills`, `mcps`, `tools` (all keyed by `name`) and `files` +(keyed by `path`). Selectors nest, and again the inner list name is not repeated: + + ["parameters","agent",{"field":"skills","key":"release-qa"}, + {"field":"files","key":"checklist.md"},"content"] + +OPERATIONS. `set` replaces one field (needs `value`). `merge` deep-merges an object into +one field (needs `value`). `remove` deletes one field. `edit_text` replaces exact +substrings in one string field (needs `edits`). `add_item` appends to a list; its target +ENDS with the list name (needs `value`). `replace_item` replaces one entry and +`remove_item` deletes one entry; their targets END with a selector. + +`replace_item` cannot rename: the new value must keep the same key. To rename an entry, +send `remove_item` and then `add_item` in the same delta. + +EDIT_TEXT is how you change part of a long text. Do not `set` a whole document to change +one line. Each edit carries `old_text` and `new_text`. `old_text` must occur exactly once +and must match character for character. Copy it out of the configuration you read; never +retype it from memory. Watch the line breaks: a sentence in the stored text may wrap +across a `\n` where a paragraph would read as one line. If you get `text_not_found`, your +whitespace is wrong — copy a shorter fragment that you can see verbatim. If you get +`text_not_unique`, add surrounding lines until the anchor is unique. + +WORKSPACE FILES. To use a file's content as a value, write `{"$content_from": ""}` +where the string would go. Paths are relative to the repository root and must be under +`imports/`. Do not invent a path: use the one you were given, and if it is refused, use a +path the error offers. + + {"operation":"add_item","target":["parameters","agent","skills"], + "value":{"name":"pdf-tools","description":"Make PDFs.", + "body":{"$content_from":"imports/pdf-tools/SKILL.md"}, + "files":[{"path":"reference.md", + "content":{"$content_from":"imports/pdf-tools/reference.md"}}]}} + +ERRORS carry a reason code and `retryable`. When it is retryable, fix the operation and +call again; do not ask the user. `stale_base_revision` means someone else committed: the +error carries the new head and its data, so re-anchor your edits against that data and +resend with the new `base_revision_id`. diff --git a/docs/design/agent-config-editing/spikes/model-usability/results.tar.gz b/docs/design/agent-config-editing/spikes/model-usability/results.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..904309a813def64c08c4df1073eebb1ed57c3684 GIT binary patch literal 38306 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.40", "httpx>=0.27"] +# /// +"""Run the task suite against one model with one instruction document. + +Usage: + uv run run.py --model haiku --instructions v1 --n 5 --out results/haiku-v1.jsonl +""" + +import argparse +import copy +import json +import os +import pathlib +import random +import sys +import threading +import time +from concurrent.futures import ThreadPoolExecutor +from typing import Any, Dict, List, Optional, Tuple + +HERE = pathlib.Path(__file__).parent +sys.path.insert(0, str(HERE)) + +import harness # noqa: E402 +import tasks as T # noqa: E402 + +MAX_ATTEMPTS = 3 # the first call plus two retries + +SYSTEM = ( + "You are an agent that can edit your own configuration. The user asks for a change. " + "Make exactly the change they ask for, and nothing else, by calling the " + f"{harness.TOOL_NAME} tool. Do not ask questions. Do not explain first; call the tool." +) + +MODELS = { + "haiku": { + "provider": "anthropic", + "id": "claude-haiku-4-5-20251001", + }, + "deepseek": { + "provider": "openrouter", + "id": "deepseek/deepseek-v4-flash", + }, +} + + +def load_env() -> None: + path = pathlib.Path.home() / ".agenta-qa-secrets.env" + if not path.exists(): + return + for line in path.read_text().splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + key = key.replace("export ", "").strip() + value = value.strip().strip('"').strip("'") + if value and key not in os.environ: + os.environ[key] = value + + +# -------------------------------------------------------------------------------------- +# Providers +# -------------------------------------------------------------------------------------- + + +class Anthropic: + def __init__(self, model_id: str, instructions: str, schema: Dict[str, Any]): + import anthropic + + self.client = anthropic.Anthropic(timeout=180.0, max_retries=10) + self.model_id = model_id + self.tools = [ + { + "name": harness.TOOL_NAME, + "description": instructions, + "input_schema": schema, + } + ] + + def call(self, messages: List[Dict[str, Any]]) -> Tuple[Any, Dict[str, Any]]: + last: Optional[Exception] = None + for attempt in range(8): + try: + response = self.client.messages.create( + model=self.model_id, + max_tokens=8000, + system=SYSTEM, + tools=self.tools, + messages=messages, + ) + break + except Exception as error: # noqa: BLE001 - retry every transport failure + last = error + time.sleep(3 * (attempt + 1) + random.random() * 3) + else: + raise RuntimeError(f"anthropic failed after 8 tries: {last}") + blocks = [b.model_dump() for b in response.content] + tool_calls = [b for b in blocks if b["type"] == "tool_use"] + text = "".join(b.get("text", "") for b in blocks if b["type"] == "text") + return ( + {"blocks": blocks, "tool_calls": tool_calls, "text": text}, + { + "input_tokens": response.usage.input_tokens, + "output_tokens": response.usage.output_tokens, + }, + ) + + def assistant_message(self, turn: Dict[str, Any]) -> Dict[str, Any]: + return {"role": "assistant", "content": turn["blocks"]} + + def tool_result_message( + self, turn: Dict[str, Any], payload: Dict[str, Any] + ) -> Dict[str, Any]: + call = turn["tool_calls"][0] + return { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": call["id"], + "content": json.dumps(payload, ensure_ascii=False), + "is_error": "error" in payload, + } + ], + } + + @staticmethod + def tool_input(turn: Dict[str, Any]) -> Tuple[bool, Any]: + """(json_ok, parsed_input). The SDK parses tool input server-side.""" + return True, turn["tool_calls"][0]["input"] + + +class OpenRouter: + def __init__(self, model_id: str, instructions: str, schema: Dict[str, Any]): + import httpx + + self.http = httpx.Client( + base_url="https://openrouter.ai/api/v1", + headers={ + "Authorization": f"Bearer {os.environ['OPENROUTER_API_KEY']}", + "Content-Type": "application/json", + }, + timeout=180.0, + ) + self.model_id = model_id + self.tools = [ + { + "type": "function", + "function": { + "name": harness.TOOL_NAME, + "description": instructions, + "parameters": schema, + }, + } + ] + + def call(self, messages: List[Dict[str, Any]]) -> Tuple[Any, Dict[str, Any]]: + body = { + "model": self.model_id, + "max_tokens": 8000, + "messages": [{"role": "system", "content": SYSTEM}] + messages, + "tools": self.tools, + } + last: Optional[Exception] = None + for attempt in range(5): + try: + response = self.http.post("/chat/completions", json=body) + if response.status_code >= 500 or response.status_code == 429: + raise RuntimeError(f"http {response.status_code}: {response.text[:200]}") + response.raise_for_status() + data = response.json() + if "choices" not in data: + raise RuntimeError(f"no choices: {json.dumps(data)[:300]}") + break + except Exception as error: # noqa: BLE001 + last = error + time.sleep(2 * (attempt + 1)) + else: + raise RuntimeError(f"openrouter failed: {last}") + + message = data["choices"][0]["message"] + usage = data.get("usage") or {} + return ( + { + "message": message, + "tool_calls": message.get("tool_calls") or [], + "text": message.get("content") or "", + }, + { + "input_tokens": usage.get("prompt_tokens", 0), + "output_tokens": usage.get("completion_tokens", 0), + }, + ) + + def assistant_message(self, turn: Dict[str, Any]) -> Dict[str, Any]: + message = dict(turn["message"]) + message.pop("reasoning", None) + message.pop("reasoning_details", None) + return message + + def tool_result_message( + self, turn: Dict[str, Any], payload: Dict[str, Any] + ) -> Dict[str, Any]: + call = turn["tool_calls"][0] + return { + "role": "tool", + "tool_call_id": call["id"], + "content": json.dumps(payload, ensure_ascii=False), + } + + @staticmethod + def tool_input(turn: Dict[str, Any]) -> Tuple[bool, Any]: + raw = turn["tool_calls"][0]["function"]["arguments"] + if isinstance(raw, dict): + return True, raw + try: + return True, json.loads(raw) + except Exception: # noqa: BLE001 + return False, raw + + +# -------------------------------------------------------------------------------------- +# One trial +# -------------------------------------------------------------------------------------- + + +def head_for(task: "T.Task") -> Tuple[Dict[str, Any], str]: + """The configuration the commit really lands on, and its revision id.""" + if task.recovery == "conflict": + return T.CONFIG_F_NEW_HEAD, T.NEW_HEAD_REVISION_ID + return task.config, task.base_revision_id + + +def run_trial(client: Any, task: "T.Task", trial: int) -> Dict[str, Any]: + head_config, head_revision_id = head_for(task) + + user = ( + "Here is the current configuration, from read_config:\n\n" + f"{harness.read_config_result(task.config, task.base_revision_id)}\n\n" + f"Task: {task.prompt}" + ) + messages: List[Dict[str, Any]] = [{"role": "user", "content": user}] + + record: Dict[str, Any] = { + "task": task.tid, + "trial": trial, + "attempts": [], + "tool_call_made": False, + "json_ok": False, + "engine_accepted": False, + "correct": False, + "recovered": False, + "attempts_used": 0, + "input_tokens": 0, + "output_tokens": 0, + "error": None, + } + + served_conflict = False + + for attempt in range(MAX_ATTEMPTS): + try: + turn, usage = client.call(messages) + except Exception as error: # noqa: BLE001 + record["error"] = f"api: {type(error).__name__}: {error}" + return record + record["input_tokens"] += usage["input_tokens"] + record["output_tokens"] += usage["output_tokens"] + record["attempts_used"] = attempt + 1 + + if not turn["tool_calls"]: + record["attempts"].append( + {"attempt": attempt, "no_tool_call": True, "text": turn["text"][:600]} + ) + record["error"] = "no tool call" + return record + + record["tool_call_made"] = True + json_ok, envelope = client.tool_input(turn) + if attempt == 0: + record["json_ok"] = json_ok + if not json_ok: + record["attempts"].append( + {"attempt": attempt, "bad_json": True, "raw": str(envelope)[:600]} + ) + record["error"] = "unparseable tool arguments" + return record + + # For the conflict flow the model's first call is aimed at the stale head; the + # commit wrapper answers 409 and hands back the moved head. + new_config, payload = harness.run_commit( + envelope, + head_config=head_config, + head_revision_id=head_revision_id, + ) + if ( + task.recovery == "conflict" + and not served_conflict + and new_config is None + and payload.get("error", {}).get("code") == "stale_base_revision" + ): + served_conflict = True + payload["error"]["head"] = { + "revision_id": head_revision_id, + "data": head_config, + } + + step = { + "attempt": attempt, + "envelope": envelope, + "ok": new_config is not None, + } + if new_config is None: + step["error"] = payload["error"] + record["attempts"].append(step) + + if new_config is not None: + record["engine_accepted"] = True + problem = task.checker(new_config) + record["correct"] = problem is None + if problem: + record["error"] = f"wrong result: {problem}" + record["final_config"] = new_config + record["recovered"] = record["correct"] and ( + task.recovery is None or attempt > 0 + ) + return record + + retryable = (payload.get("error") or {}).get("retryable") + if retryable is None: + retryable = ((payload.get("error") or {}).get("reason") or {}).get( + "retryable", True + ) + if payload["error"].get("code") == "change_set_rejected": + retryable = payload["error"].get("retryable", True) + + messages.append(client.assistant_message(turn)) + messages.append(client.tool_result_message(turn, payload)) + + record["error"] = "gave up after {} attempts".format(MAX_ATTEMPTS) + return record + + +# -------------------------------------------------------------------------------------- +# Main +# -------------------------------------------------------------------------------------- + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--model", required=True, choices=sorted(MODELS)) + parser.add_argument("--instructions", required=True) + parser.add_argument("--n", type=int, default=5) + parser.add_argument("--tasks", default="") + parser.add_argument("--union-schema", action="store_true") + parser.add_argument("--workers", type=int, default=3) + parser.add_argument("--rich-errors", action="store_true") + parser.add_argument("--lenient", action="store_true") + parser.add_argument("--out", required=True) + args = parser.parse_args() + + load_env() + harness.RICH_ERRORS = args.rich_errors or args.lenient + harness.LENIENT = args.lenient + + instructions = (HERE / "instructions" / f"{args.instructions}.md").read_text() + schema = harness.tool_schema(union=args.union_schema) + spec = MODELS[args.model] + + selected = [t for t in T.TASKS if not args.tasks or t.tid in args.tasks.split(",")] + + lock = threading.Lock() + out = pathlib.Path(args.out) + out.parent.mkdir(parents=True, exist_ok=True) + handle = out.open("w") + + if spec["provider"] == "anthropic": + client = Anthropic(spec["id"], instructions, schema) + else: + client = OpenRouter(spec["id"], instructions, schema) + + def work(item: Tuple["T.Task", int]) -> Dict[str, Any]: + task, trial = item + record = run_trial(client, task, trial) + record["model"] = args.model + record["instructions"] = args.instructions + record["union_schema"] = args.union_schema + record["rich_errors"] = args.rich_errors + record["lenient"] = args.lenient + with lock: + handle.write(json.dumps(record, ensure_ascii=False) + "\n") + handle.flush() + flag = "OK " if record["correct"] else "BAD" + print( + f"{flag} {args.model}/{args.instructions} task {task.tid} " + f"trial {trial}: {record['error'] or 'correct'}", + flush=True, + ) + return record + + jobs = [(task, trial) for task in selected for trial in range(args.n)] + with ThreadPoolExecutor(max_workers=args.workers) as pool: + records = list(pool.map(work, jobs)) + handle.close() + + print("\n=== summary ===") + for task in selected: + rows = [r for r in records if r["task"] == task.tid] + n = len(rows) + print( + f" {task.tid} tool_call {sum(r['tool_call_made'] for r in rows)}/{n} " + f"engine_ok {sum(r['engine_accepted'] for r in rows)}/{n} " + f"correct {sum(r['correct'] for r in rows)}/{n}" + ) + total = len(records) + print( + f" ALL correct {sum(r['correct'] for r in records)}/{total} " + f"tokens in {sum(r['input_tokens'] for r in records)} " + f"out {sum(r['output_tokens'] for r in records)}" + ) + + +if __name__ == "__main__": + main() diff --git a/docs/design/agent-config-editing/spikes/model-usability/selftest.py b/docs/design/agent-config-editing/spikes/model-usability/selftest.py new file mode 100644 index 0000000000..f081ca88d6 --- /dev/null +++ b/docs/design/agent-config-editing/spikes/model-usability/selftest.py @@ -0,0 +1,329 @@ +# /// script +# requires-python = ">=3.11" +# dependencies = [] +# /// +"""Prove every task is solvable: hand-written golden deltas must pass their checkers. + +It also proves the negative cases fire: (f) must 409 on the stale base, (g) must give +text_not_unique on the short anchor, (h) must refuse the scratch/ path. +""" + +import pathlib +import sys + +HERE = pathlib.Path(__file__).parent +sys.path.insert(0, str(HERE)) + +import harness # noqa: E402 +import tasks as T # noqa: E402 + +AGENT = ["parameters", "agent"] + +GOLDEN = { + "a": { + "operations": [ + { + "operation": "edit_text", + "target": AGENT + ["instructions", "agents_md"], + "edits": [ + { + "old_text": "Run the checks manually when the suite is\nunavailable.", + "new_text": "Run the release-qa skill instead.", + } + ], + } + ] + }, + "b": { + "operations": [ + { + "operation": "edit_text", + "target": AGENT + [{"field": "skills", "key": "release-qa"}, "body"], + "edits": [ + { + "old_text": "pytest -m smoke", + "new_text": "pytest -m release", + } + ], + } + ] + }, + "c": { + "operations": [ + { + "operation": "add_item", + "target": AGENT + ["tools"], + "value": {"type": "builtin", "name": "run_shell_command"}, + } + ] + }, + "d": { + "operations": [ + { + "operation": "remove_item", + "target": AGENT + [{"field": "mcps", "key": "linear"}], + } + ] + }, + "e": { + "operations": [ + { + "operation": "add_item", + "target": AGENT + ["skills"], + "value": { + "name": "pdf-tools", + "description": "Make and merge PDF files.", + "body": {"$content_from": "imports/pdf-tools/SKILL.md"}, + "files": [ + { + "path": "reference.md", + "content": { + "$content_from": "imports/pdf-tools/reference.md" + }, + } + ], + }, + } + ] + }, + "f": { + "operations": [ + { + "operation": "edit_text", + "target": AGENT + ["instructions", "agents_md"], + "edits": [ + {"old_text": "Do not use emojis.", "new_text": "Use plain language."} + ], + } + ] + }, + "g": { + "operations": [ + { + "operation": "edit_text", + "target": AGENT + [{"field": "skills", "key": "onboarding"}, "body"], + "edits": [ + { + "old_text": "Ask the new hire for their laptop serial number.\nCreate the accounts", + "new_text": "Ask the new hire for their GitHub username.\nCreate the accounts", + } + ], + } + ] + }, + "h": { + "operations": [ + { + "operation": "add_item", + "target": AGENT + ["skills"], + "value": { + "name": "pdf-tools", + "description": "Make and merge PDF files.", + "body": {"$content_from": "imports/pdf-tools/SKILL.md"}, + "files": [ + { + "path": "reference.md", + "content": { + "$content_from": "imports/pdf-tools/reference.md" + }, + } + ], + }, + } + ] + }, + "i": { + "operations": [ + { + "operation": "remove_item", + "target": AGENT + [{"field": "skills", "key": "triage"}], + }, + { + "operation": "add_item", + "target": AGENT + ["skills"], + "value": { + "name": "issue-triage", + "description": "Triage incoming issues.", + "body": "# Triage\n\nLabel the issue. Assign a priority.\n", + "files": [], + }, + }, + ] + }, + "j": { + "operations": [ + { + "operation": "edit_text", + "target": AGENT + + [ + {"field": "skills", "key": "release-qa"}, + {"field": "files", "key": "checklist.md"}, + "content", + ], + "edits": [ + { + "old_text": "- [ ] deploy logs\n", + "new_text": "- [ ] deploy logs\n- [ ] release notes\n", + } + ], + } + ] + }, + "k": { + "operations": [ + { + "operation": "set", + "target": AGENT + ["llm", "model"], + "value": "anthropic/claude-opus-5", + }, + { + "operation": "remove_item", + "target": AGENT + [{"field": "tools", "key": "send-slack-message"}], + }, + { + "operation": "edit_text", + "target": AGENT + ["instructions", "agents_md"], + "edits": [ + {"old_text": "Do not use emojis.", "new_text": "Use plain language."} + ], + }, + ] + }, +} + + +def head_for(task): + if task.recovery == "conflict": + return T.CONFIG_F_NEW_HEAD, T.NEW_HEAD_REVISION_ID + return task.config, task.base_revision_id + + +failures = [] + +for task in T.TASKS: + head_config, head_revision_id = head_for(task) + envelope = { + "workflow_revision": { + "base_revision_id": head_revision_id, + "message": "test", + "delta": GOLDEN[task.tid], + } + } + config, payload = harness.run_commit( + envelope, head_config=head_config, head_revision_id=head_revision_id + ) + if config is None: + failures.append(f"{task.tid}: engine refused the golden delta: {payload}") + continue + problem = task.checker(config) + if problem: + failures.append(f"{task.tid}: checker rejected the golden result: {problem}") + else: + print(f"golden {task.tid}: ok") + +# --- the negative cases must fire --- + +stale = { + "workflow_revision": { + "base_revision_id": T.HEAD_REVISION_ID, + "message": "test", + "delta": GOLDEN["f"], + } +} +config, payload = harness.run_commit( + stale, head_config=T.CONFIG_F_NEW_HEAD, head_revision_id=T.NEW_HEAD_REVISION_ID +) +if config is not None or payload["error"]["code"] != "stale_base_revision": + failures.append(f"f: the stale base did not 409: {payload}") +else: + print("negative f: stale base 409 ok") + +short = { + "workflow_revision": { + "base_revision_id": T.HEAD_REVISION_ID, + "message": "test", + "delta": { + "operations": [ + { + "operation": "edit_text", + "target": AGENT + [{"field": "skills", "key": "onboarding"}, "body"], + "edits": [ + { + "old_text": "Ask the new hire for their laptop serial number.", + "new_text": "Ask the new hire for their GitHub username.", + } + ], + } + ] + }, + } +} +config, payload = harness.run_commit( + short, head_config=T.CONFIG_G, head_revision_id=T.HEAD_REVISION_ID +) +if config is not None or payload["error"]["reason"]["code"] != "text_not_unique": + failures.append(f"g: the ambiguous anchor did not fire: {payload}") +else: + print( + f"negative g: text_not_unique ok " + f"(match_count={payload['error']['reason'].get('match_count')})" + ) + +wrong_folder = { + "workflow_revision": { + "base_revision_id": T.HEAD_REVISION_ID, + "message": "test", + "delta": { + "operations": [ + { + "operation": "add_item", + "target": AGENT + ["skills"], + "value": { + "name": "pdf-tools", + "description": "Make and merge PDF files.", + "body": {"$content_from": "scratch/pdf-tools/SKILL.md"}, + }, + } + ] + }, + } +} +config, payload = harness.run_commit( + wrong_folder, head_config=T.BASE_CONFIG, head_revision_id=T.HEAD_REVISION_ID +) +if config is not None or payload["error"]["reason"]["code"] != "source_outside_import_root": + failures.append(f"h: the wrong folder was not refused: {payload}") +else: + print("negative h: source_outside_import_root ok") + print(" hint given:", payload["error"]["reason"]["folders_under_import_root"]) + +# --- a wholesale set of the instructions must NOT pass check_a --- +wholesale = { + "workflow_revision": { + "base_revision_id": T.HEAD_REVISION_ID, + "message": "test", + "delta": { + "operations": [ + { + "operation": "set", + "target": AGENT + ["instructions", "agents_md"], + "value": "Run the release-qa skill instead.", + } + ] + }, + } +} +config, payload = harness.run_commit( + wholesale, head_config=T.BASE_CONFIG, head_revision_id=T.HEAD_REVISION_ID +) +if config is None or T.check_a(config) is None: + failures.append("a: a truncating wholesale set was accepted by the checker") +else: + print("negative a: truncating set rejected by the checker") + +print() +if failures: + for failure in failures: + print("FAIL:", failure) + sys.exit(1) +print("all task fixtures are solvable and every negative case fires") diff --git a/docs/design/agent-config-editing/spikes/model-usability/table.py b/docs/design/agent-config-editing/spikes/model-usability/table.py new file mode 100644 index 0000000000..c96d31fbdc --- /dev/null +++ b/docs/design/agent-config-editing/spikes/model-usability/table.py @@ -0,0 +1,111 @@ +# /// script +# requires-python = ">=3.11" +# dependencies = [] +# /// +"""Emit the markdown result tables for the spike report.""" + +import collections +import glob +import json +import pathlib +import sys + +HERE = pathlib.Path(__file__).parent +sys.path.insert(0, str(HERE)) +import tasks as T # noqa: E402 + +TASK_IDS = [t.tid for t in T.TASKS] +TITLES = {t.tid: t.title for t in T.TASKS} + +ARMS = [ + ("haiku", "v0", False, "Haiku v0"), + ("haiku", "v1", False, "Haiku v1"), + ("haiku", "v2", False, "Haiku v2"), + ("haiku", "v2", True, "Haiku v2+L"), + ("deepseek", "v0", False, "DS v0"), + ("deepseek", "v1", False, "DS v1"), + ("deepseek", "v2", False, "DS v2"), + ("deepseek", "v2", True, "DS v2+L"), +] + +rows = [] +for path in sorted(glob.glob(str(HERE / "results" / "*.jsonl"))): + if "smoke" in path: + continue + for line in open(path): + rows.append(json.loads(line)) + +by_arm = collections.defaultdict(list) +for row in rows: + by_arm[(row["model"], row["instructions"], row.get("lenient", False))].append(row) + + +def cell(batch, field): + if not batch: + return "-" + return f"{sum(r[field] for r in batch)}/{len(batch)}" + + +print("### Correct final configuration, by task\n") +head = "| Task | What it asks | " + " | ".join(a[3] for a in ARMS) + " |" +print(head) +print("|---|---|" + "---|" * len(ARMS)) +for tid in TASK_IDS: + cells = [] + for model, version, lenient, _ in ARMS: + batch = [r for r in by_arm[(model, version, lenient)] if r["task"] == tid] + cells.append(cell(batch, "correct")) + print(f"| {tid} | {TITLES[tid]} | " + " | ".join(cells) + " |") +totals = [] +for model, version, lenient, _ in ARMS: + batch = by_arm[(model, version, lenient)] + totals.append(cell(batch, "correct")) +print("| **all** | | " + " | ".join(f"**{t}**" for t in totals) + " |") + +print("\n### Pipeline rates (all tasks pooled)\n") +print("| Arm | n | called the tool | valid JSON (first call) | engine accepted | correct |") +print("|---|---|---|---|---|---|") +for model, version, lenient, label in ARMS: + batch = by_arm[(model, version, lenient)] + if not batch: + continue + n = len(batch) + + def pct(field): + return f"{cell(batch, field)} ({100 * sum(r[field] for r in batch) // n}%)" + + print( + f"| {label} | {n} | {pct('tool_call_made')} | {pct('json_ok')} | " + f"{pct('engine_accepted')} | {pct('correct')} |" + ) + +print("\n### Recovery tasks: did the model fix itself within two retries?\n") +print("| Arm | f (409 conflict) | g (ambiguous anchor) | h (wrong folder) |") +print("|---|---|---|---|") +for model, version, lenient, label in ARMS: + arm = by_arm[(model, version, lenient)] + if not arm: + continue + cells = [] + for tid in ("f", "g", "h"): + batch = [r for r in arm if r["task"] == tid] + if not batch: + cells.append("-") + continue + first = sum(1 for r in batch if r["correct"] and r["attempts_used"] == 1) + after = sum(1 for r in batch if r["correct"] and r["attempts_used"] > 1) + never = len(batch) - first - after + cells.append(f"{first} first call, {after} after retry, {never} never") + print(f"| {label} | " + " | ".join(cells) + " |") + +print("\n### Cost\n") +print("| Arm | input tokens | output tokens |") +print("|---|---|---|") +for model, version, lenient, label in ARMS: + batch = by_arm[(model, version, lenient)] + if not batch: + continue + print( + f"| {label} | {sum(r['input_tokens'] for r in batch):,} | " + f"{sum(r['output_tokens'] for r in batch):,} |" + ) diff --git a/docs/design/agent-config-editing/spikes/model-usability/tasks.py b/docs/design/agent-config-editing/spikes/model-usability/tasks.py new file mode 100644 index 0000000000..a8a0954f7d --- /dev/null +++ b/docs/design/agent-config-editing/spikes/model-usability/tasks.py @@ -0,0 +1,481 @@ +"""The task suite: base configs, natural-language tasks, and automatic checkers. + +Every checker receives the final config the engine produced and returns None on success +or a short failure string. +""" + +import copy +from typing import Any, Callable, Dict, List, Optional + +# -------------------------------------------------------------------------------------- +# The base configuration: one realistic agent template +# -------------------------------------------------------------------------------------- + +AGENTS_MD = """# Release QA agent + +You help the team ship a release. You check the build, you run the QA suite, and you +write the release notes. + +## How you work + +Always read the changelog before you start. Run the checks manually when the suite is +unavailable. Report every failure with its full log line. + +## Tone + +Be brief. Use short sentences. Do not use emojis. +""" + +RELEASE_QA_BODY = """# Release QA + +Run this skill before every release. + +1. Pull the release branch. +2. Run the smoke suite with `pytest -m smoke`. +3. Check the deploy logs for errors. +4. Post the result in the release channel. + +Escalate to the on-call engineer when step 2 fails. +""" + +CHANGELOG_BODY = """# Changelog writer + +Write one entry per merged pull request. + +Keep each entry to one sentence. Link the pull request. Group entries by area. +""" + +BASE_CONFIG: Dict[str, Any] = { + "parameters": { + "agent": { + "llm": { + "model": "anthropic/claude-sonnet-5", + "max_tokens": 8192, + }, + "instructions": { + "agents_md": AGENTS_MD, + }, + "skills": [ + { + "name": "release-qa", + "description": "Run the release QA suite.", + "body": RELEASE_QA_BODY, + "files": [ + { + "path": "checklist.md", + "content": "- [ ] smoke suite\n- [ ] deploy logs\n", + } + ], + }, + { + "name": "changelog-writer", + "description": "Write the changelog.", + "body": CHANGELOG_BODY, + "files": [], + }, + { + "name": "triage", + "description": "Triage incoming issues.", + "body": "# Triage\n\nLabel the issue. Assign a priority.\n", + "files": [], + }, + ], + "tools": [ + {"type": "builtin", "name": "read_file"}, + {"type": "builtin", "name": "write_file"}, + { + "type": "gateway", + "name": "send-slack-message", + "integration": "slack", + "action": "post_message", + }, + ], + "mcps": [ + { + "name": "github", + "transport": "http", + "url": "https://mcp.github.example/sse", + }, + { + "name": "linear", + "transport": "http", + "url": "https://mcp.linear.example/sse", + }, + ], + } + } +} + +HEAD_REVISION_ID = "019c8a10-0000-7000-8000-000000000001" +NEW_HEAD_REVISION_ID = "019c8a10-0000-7000-8000-000000000002" + +# The simulated workspace the runner can read. Keys are paths relative to the repo root. +WORKSPACE: Dict[str, str] = { + "imports/pdf-tools/SKILL.md": ( + "---\nname: pdf-tools\ndescription: Make and merge PDF files.\n---\n" + "# PDF tools\n\nMake and merge PDF files.\n\n" + "Use `pdftk` to merge. Use `weasyprint` to render HTML to PDF.\n" + ), + "imports/pdf-tools/reference.md": ( + "# Reference\n\n- merge: `pdftk a.pdf b.pdf cat output out.pdf`\n" + "- render: `weasyprint in.html out.pdf`\n" + ), + # The same folder, misplaced. Task (h) points here first. + "scratch/pdf-tools/SKILL.md": ( + "---\nname: pdf-tools\ndescription: Make and merge PDF files.\n---\n" + "# PDF tools\n\nA stale copy. Do not import this.\n" + ), +} + +IMPORT_ROOT = "imports/" + + +# -------------------------------------------------------------------------------------- +# Helpers for the checkers +# -------------------------------------------------------------------------------------- + + +def agent(config: Dict[str, Any]) -> Dict[str, Any]: + return config["parameters"]["agent"] + + +def find(items: List[Dict[str, Any]], key: str, value: str) -> Optional[Dict[str, Any]]: + for item in items: + if item.get(key) == value: + return item + return None + + +def unchanged_except( + config: Dict[str, Any], *, allowed: List[str] +) -> Optional[str]: + """Every top-level agent branch outside `allowed` must equal the base.""" + base = agent(BASE_CONFIG) + got = agent(config) + for branch in ("llm", "instructions", "skills", "tools", "mcps"): + if branch in allowed: + continue + if got.get(branch) != base.get(branch): + return f"collateral damage: '{branch}' changed but should not have" + return None + + +# -------------------------------------------------------------------------------------- +# The tasks +# -------------------------------------------------------------------------------------- + + +def check_a(config: Dict[str, Any]) -> Optional[str]: + text = agent(config)["instructions"]["agents_md"] + if "Run the checks manually when the suite is\nunavailable." in text: + return "the old sentence is still there" + if "release-qa skill" not in text: + return "the new sentence does not mention the release-qa skill" + # Everything else in the document survives. + for keep in ("# Release QA agent", "Be brief.", "Always read the changelog"): + if keep not in text: + return f"the rewrite dropped surrounding text: {keep!r}" + return unchanged_except(config, allowed=["instructions"]) + + +def check_b(config: Dict[str, Any]) -> Optional[str]: + skill = find(agent(config)["skills"], "name", "release-qa") + if skill is None: + return "the release-qa skill is gone" + body = skill["body"] + if "pytest -m smoke" in body: + return "the old command is still there" + if "pytest -m release" not in body: + return "the new command 'pytest -m release' is missing" + for keep in ("1. Pull the release branch.", "Escalate to the on-call engineer"): + if keep not in body: + return f"the rewrite dropped surrounding text: {keep!r}" + if len(agent(config)["skills"]) != 3: + return "the skills list changed length" + other = find(agent(config)["skills"], "name", "changelog-writer") + if other != find(agent(BASE_CONFIG)["skills"], "name", "changelog-writer"): + return "another skill changed" + return unchanged_except(config, allowed=["skills"]) + + +def check_c(config: Dict[str, Any]) -> Optional[str]: + tools = agent(config)["tools"] + if len(tools) != 4: + return f"expected 4 tools, found {len(tools)}" + names = [t.get("name") or t.get("op") or t.get("slug") for t in tools] + if "run_shell_command" not in names: + return f"the new tool is missing; found {names}" + for old in ("read_file", "write_file", "send-slack-message"): + if old not in names: + return f"the existing tool {old!r} disappeared" + return unchanged_except(config, allowed=["tools"]) + + +def check_d(config: Dict[str, Any]) -> Optional[str]: + mcps = agent(config)["mcps"] + names = [m.get("name") for m in mcps] + if "linear" in names: + return "the linear server is still there" + if names != ["github"]: + return f"expected only ['github'], found {names}" + return unchanged_except(config, allowed=["mcps"]) + + +def check_e(config: Dict[str, Any]) -> Optional[str]: + skills = agent(config)["skills"] + if len(skills) != 4: + return f"expected 4 skills, found {len(skills)}" + skill = find(skills, "name", "pdf-tools") + if skill is None: + return f"the pdf-tools skill is missing; found {[s.get('name') for s in skills]}" + body = skill.get("body") + if not isinstance(body, str) or "weasyprint" not in body: + return "the skill body does not carry the SKILL.md content" + if "$content_from" in str(skill) or "value_from" in str(skill): + return "an unresolved content marker survived into the config" + files = skill.get("files") or [] + ref = find(files, "path", "reference.md") + if ref is None: + return f"reference.md is missing; found {[f.get('path') for f in files]}" + if "pdftk a.pdf b.pdf" not in (ref.get("content") or ""): + return "reference.md does not carry the file content" + return unchanged_except(config, allowed=["skills"]) + + +def check_f(config: Dict[str, Any]) -> Optional[str]: + """Same edit as (a), but committed on top of the moved head.""" + text = agent(config)["instructions"]["agents_md"] + if "Escalate every production incident" not in text: + return "the concurrent edit was lost (the model committed on the stale base)" + if "Do not use emojis." in text: + return "the old tone sentence is still there" + if "Use plain language." not in text: + return "the new tone sentence is missing" + return unchanged_except(config, allowed=["instructions"]) + + +def check_g(config: Dict[str, Any]) -> Optional[str]: + body = find(agent(config)["skills"], "name", "onboarding")["body"] + if body.count("Ask the new hire for their laptop serial number.") != 1: + return "expected exactly one of the two duplicated sentences to survive" + if "Ask the new hire for their GitHub username." not in body: + return "the replacement sentence is missing" + # The replacement must land in the second (Accounts) section, not the first. + accounts = body.split("## Accounts", 1) + if len(accounts) != 2: + return "the Accounts section is gone" + if "Ask the new hire for their GitHub username." not in accounts[1]: + return "the edit landed in the wrong section" + return None + + +def check_h(config: Dict[str, Any]) -> Optional[str]: + return check_e(config) + + +# -------------------------------------------------------------------------------------- +# Task (g) needs its own base: a skill body with a duplicated sentence. +# -------------------------------------------------------------------------------------- + +ONBOARDING_BODY = """# Onboarding + +## Hardware + +Ask the new hire for their laptop serial number. +Register the laptop in the asset tracker. + +## Accounts + +Ask the new hire for their laptop serial number. +Create the accounts in the identity provider. +""" + +CONFIG_G = copy.deepcopy(BASE_CONFIG) +CONFIG_G["parameters"]["agent"]["skills"].append( + { + "name": "onboarding", + "description": "Onboard a new hire.", + "body": ONBOARDING_BODY, + "files": [], + } +) + +# Task (f) needs a second config: the head moved between the read and the commit. +CONFIG_F_NEW_HEAD = copy.deepcopy(BASE_CONFIG) +CONFIG_F_NEW_HEAD["parameters"]["agent"]["instructions"]["agents_md"] = AGENTS_MD.replace( + "## Tone", + "Escalate every production incident to the on-call engineer within five minutes.\n\n## Tone", +) + + +def check_i(config: Dict[str, Any]) -> Optional[str]: + skills = agent(config)["skills"] + names = [s.get("name") for s in skills] + if "triage" in names: + return "the old name is still there" + if "issue-triage" not in names: + return f"the renamed skill is missing; found {names}" + if len(skills) != 3: + return f"expected 3 skills, found {len(skills)}" + renamed = find(skills, "name", "issue-triage") + if renamed.get("body") != "# Triage\n\nLabel the issue. Assign a priority.\n": + return "the rename lost or rewrote the body" + if renamed.get("description") != "Triage incoming issues.": + return "the rename lost the description" + return unchanged_except(config, allowed=["skills"]) + + +def check_j(config: Dict[str, Any]) -> Optional[str]: + skill = find(agent(config)["skills"], "name", "release-qa") + files = skill.get("files") or [] + checklist = find(files, "path", "checklist.md") + if checklist is None: + return "checklist.md is gone" + content = checklist.get("content") or "" + if "release notes" not in content: + return f"the new line is missing; content is {content!r}" + if "- [ ] smoke suite" not in content or "- [ ] deploy logs" not in content: + return f"an existing line was lost; content is {content!r}" + if skill.get("body") != RELEASE_QA_BODY: + return "the skill body was touched" + return unchanged_except(config, allowed=["skills"]) + + +def check_k(config: Dict[str, Any]) -> Optional[str]: + a = agent(config) + if a["llm"].get("model") != "anthropic/claude-opus-5": + return f"the model is {a['llm'].get('model')!r}" + if a["llm"].get("max_tokens") != 8192: + return "max_tokens was lost: the whole llm object was replaced" + names = [t.get("name") for t in a["tools"]] + if "send-slack-message" in names: + return "the slack tool is still there" + if len(a["tools"]) != 2: + return f"expected 2 tools, found {names}" + text = a["instructions"]["agents_md"] + if "Do not use emojis." in text: + return "the emoji sentence is still there" + if "Use plain language." not in text: + return "the replacement sentence is missing" + if "# Release QA agent" not in text: + return "the instruction rewrite truncated the document" + if a["skills"] != agent(BASE_CONFIG)["skills"]: + return "the skills changed" + return None + + +class Task: + def __init__( + self, + tid: str, + title: str, + prompt: str, + checker: Callable[[Dict[str, Any]], Optional[str]], + *, + config: Optional[Dict[str, Any]] = None, + base_revision_id: str = HEAD_REVISION_ID, + recovery: Optional[str] = None, + ) -> None: + self.tid = tid + self.title = title + self.prompt = prompt + self.checker = checker + self.config = config if config is not None else BASE_CONFIG + self.base_revision_id = base_revision_id + # "conflict" | "ambiguous" | "import_root" | None + self.recovery = recovery + + +TASKS: List[Task] = [ + Task( + "a", + "edit one instruction sentence", + "In your instructions, we no longer want the manual fallback. Replace the " + "sentence that says to run the checks manually with one that says to run the " + "release-qa skill instead. Leave the rest of the document exactly as it is.", + check_a, + ), + Task( + "b", + "change one line in one skill body", + "The smoke marker was renamed. In the release-qa skill, step 2 must now run " + "`pytest -m release` instead of `pytest -m smoke`. Change only that.", + check_b, + ), + Task( + "c", + "add one tool by name", + "Give me a new builtin tool called run_shell_command so I can run shell " + "commands. Keep every tool I already have.", + check_c, + ), + Task( + "d", + "remove one MCP server", + "We dropped Linear. Remove the linear MCP server from my configuration. Keep " + "GitHub.", + check_d, + ), + Task( + "e", + "add a skill from workspace files", + "I wrote a new skill in the workspace at imports/pdf-tools/. It has " + "SKILL.md (the skill body) and reference.md (a bundled file). Add it as a skill " + "named pdf-tools with the description 'Make and merge PDF files.'. Do not " + "retype the file contents; pull them from those paths.", + check_e, + ), + Task( + "f", + "conflict, then retry on the new head", + "In the Tone section of my instructions, replace 'Do not use emojis.' with " + "'Use plain language.'. Change nothing else.", + check_f, + recovery="conflict", + ), + Task( + "g", + "ambiguous anchor, then retry with more context", + "In the onboarding skill, the Accounts section should ask for the GitHub " + "username, not the laptop serial number. Fix that one line. The Hardware " + "section must keep asking for the laptop serial number.", + check_g, + config=CONFIG_G, + recovery="ambiguous", + ), + Task( + "h", + "import from the wrong folder, then correct the path", + "Add the pdf-tools skill from my workspace folder scratch/pdf-tools/. It has " + "SKILL.md (the skill body) and reference.md (a bundled file). Name it pdf-tools " + "with the description 'Make and merge PDF files.'. Do not retype the file " + "contents; pull them from those paths.", + check_h, + recovery="import_root", + ), + Task( + "i", + "rename a skill, keeping its content", + "Rename my triage skill to issue-triage. Keep its description and its body " + "exactly as they are.", + check_i, + ), + Task( + "j", + "edit a line inside a bundled skill file", + "The release-qa skill bundles a file called checklist.md. Add a third checkbox " + "line to it for the release notes. Keep the two lines that are already there, " + "and do not touch anything else in the skill.", + check_j, + ), + Task( + "k", + "three changes in one commit", + "Three things, please. Switch my model to anthropic/claude-opus-5. Drop the " + "send-slack-message tool. And in my instructions, replace 'Do not use emojis.' " + "with 'Use plain language.'.", + check_k, + ), +] + +TASKS_BY_ID = {t.tid: t for t in TASKS} From a3924b91ad2eda4101251e8780ea1fd49a67ceb1 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Wed, 5 Aug 2026 11:09:39 +0200 Subject: [PATCH 14/36] docs(design): post-spike arbitrations: @ag.file marker, instructional errors, path normalization, selector forgiveness, optional free-text on all tools --- docs/design/agent-config-editing/decisions.md | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/docs/design/agent-config-editing/decisions.md b/docs/design/agent-config-editing/decisions.md index 48d8b6defe..8ede0604ed 100644 --- a/docs/design/agent-config-editing/decisions.md +++ b/docs/design/agent-config-editing/decisions.md @@ -87,6 +87,36 @@ reasoning is in `spikes/engine-spike.md` (D1-D33, O1-O12) and `spikes/runner-spi pretending to secure the channel: worst case is a stale model-visible catalog, never a privilege escalation. adapter-matrix.md §4.3. +## Arbitrations after the model-usability spike (team lead + Mahmoud, 5 August) + +- **The inline marker replaces the operation-level source, and it is named + `@ag.file`.** One marker family with `@ag.embed`: same shape, different lifetime + (embed persists and re-resolves; file is consumed at commit and never persists). + The folder-to-skill codec is dropped from v1; the agent authors skill structure + itself and references file contents per field. Validated by the spike: the + operation-level source produced the only silent-corruption failure mode; the + marker went 91-for-91 across both models. +- **Every retryable error carries one sentence naming the next action.** The + conflict response instructs: call read_config, re-anchor, resend with the new + base id. "File not found" lists what exists under the import root. "Text not + found" returns the nearest lines of the target. +- **Paths may be relative to the workspace root or absolute.** An absolute path + inside the workspace is normalized by the runner (it knows its own root on each + platform). Only paths outside the workspace are refused. Agents use absolute + paths naturally; rejecting them fights the model. +- **The wrapper forgives the two unambiguous selector mistakes** (repeated list + name; key-field in the `field` slot), and `field` is renamed to `list` in the + selector. The selector caused 62 percent of all spike failures. +- **No-change detection is mandatory before ship.** A cornered model commits a + no-op to manufacture success; the `changed` flag and the no-change response stop + it. Observed once in the spike. +- **Free-text fields on ALL builder tools are optional, with a server-derived + fallback** (Mahmoud, 5 August: general rule, not commit-only). The commit + `message` is the first case; the ephemeral `description` and any future prose + field follow the same rule. Reason: free text was the site of every DeepSeek + argument-corruption failure, and a required prose field lets a formatting slip + destroy a correct payload. + ## Settled by the contracts (no longer open) - Binary and unsupported files reject the whole import by default; `on_unsupported: From a00a345d47b8c93444360b4eb13ff7aa0cded852 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Wed, 5 Aug 2026 11:25:53 +0200 Subject: [PATCH 15/36] docs(design): v3 arbitrations: 1.5KB instructions ship with three conditions; message derived server-side; invalid_operation split; v3 spike results --- docs/design/agent-config-editing/decisions.md | 25 ++- .../spikes/model-usability-spike.md | 196 +++++++++++++++++- .../spikes/model-usability/harness.py | 72 ++++++- 3 files changed, 275 insertions(+), 18 deletions(-) diff --git a/docs/design/agent-config-editing/decisions.md b/docs/design/agent-config-editing/decisions.md index 8ede0604ed..48283f367c 100644 --- a/docs/design/agent-config-editing/decisions.md +++ b/docs/design/agent-config-editing/decisions.md @@ -111,11 +111,26 @@ reasoning is in `spikes/engine-spike.md` (D1-D33, O1-O12) and `spikes/runner-spi no-op to manufacture success; the `changed` flag and the no-change response stop it. Observed once in the spike. - **Free-text fields on ALL builder tools are optional, with a server-derived - fallback** (Mahmoud, 5 August: general rule, not commit-only). The commit - `message` is the first case; the ephemeral `description` and any future prose - field follow the same rule. Reason: free text was the site of every DeepSeek - argument-corruption failure, and a required prose field lets a formatting slip - destroy a correct payload. + fallback** (Mahmoud, 5 August: general rule, not commit-only). Reason: free text + was the site of every DeepSeek argument-corruption failure. +- **Superseded for the commit specifically, by the v3 measurement:** optional is + not enough. The model volunteers a message anyway and still corrupts it. The + commit `message` LEAVES the model-facing schema entirely; the server derives it + from the operations ("edited instructions (2 edits); added skill pdf-tools"). + This also serves issues #5187/#5200 better than a model-written message: the + derived text is always accurate. The ephemeral R12 `description`, when present, + is appended as flavor. +- **The v3 instruction document ships as the tool description: ~1.5 KB, ~400 + tokens**, same success rate as the 3.2 KB version (Haiku 55/55, DeepSeek 54/55), + 11-13 percent cheaper per task. Three conditions are part of the decision, not + optional: the wrapper normalizes the repeated-list-name mistake (it absorbs 12 + percent of Haiku's targets once the teaching leaves the document); every error + carries a next-step sentence and enriched content; the selector key is `list` + (measured: zero models ever misused it, and the key-field mistake vanished). +- **`invalid_operation` splits into retryable shape errors and non-retryable + refusals.** The rename case (key mismatch on replace_item) gets its own + retryable code with a next-step ("send remove_item then add_item"). An agent + honoring retryable:false would otherwise dead-end on every rename. ## Settled by the contracts (no longer open) diff --git a/docs/design/agent-config-editing/spikes/model-usability-spike.md b/docs/design/agent-config-editing/spikes/model-usability-spike.md index 7591ce41c9..15700bd526 100644 --- a/docs/design/agent-config-editing/spikes/model-usability-spike.md +++ b/docs/design/agent-config-editing/spikes/model-usability-spike.md @@ -460,12 +460,202 @@ harness.py runner + commit wrapper + tool schema + the lenient arm run.py one arm: uv run run.py --model haiku --instructions v2 --n 5 --out ... analyze.py rates and failure modes table.py the markdown tables in section 2 -instructions/ v0.md, v1.md, v2.md -results.tar.gz 440 trials as JSONL, plus the generated tables +instructions/ v0.md, v1.md, v2.md, v3.md +results.tar.gz 550 trials as JSONL, plus the generated tables ``` `run.py` needs `change_set.py` beside it: copy it from `api/oss/src/core/workflows/change_set.py` in worktree `agent-a2a2adaa5d154d454`, or from -wherever the engine lands after slice 1. Add `--lenient` for the interface arm. +wherever the engine lands after slice 1. Add `--lenient` for the section 5 interface +arm, and `--lenient --v3-surface` for the follow-up arm. Keys are read from `~/.agenta-qa-secrets.env`. No key value is written to any output file. + +--- + +# Follow-up: how small can the instructions get? + +Added 5 August 2026, at the team lead's request. Mahmoud finds 3.2 KB heavy and asked for +the floor. + +**Answer: 1,545 bytes / 392 tokens, at no measured loss.** v3 is 48% of v2's size and +scores the same: Haiku 55/55, DeepSeek 54/55. The saving is real but it is not free — it +is paid for by the interface, and section F.4 says exactly how much. + +## F.1 What v3 assumes + +The follow-up treats five interface behaviors as decided and simulates them in the +harness, the way section 5 simulated leniency: + +1. the wrapper forgives both selector mistakes, so the WRONG/WRONG/RIGHT block is dropped; +2. the selector key is named `list`, not `field`; +3. every retryable error carries a `next_step` sentence, plus the enriched content from + section 5 (folders that exist, nearest lines on `text_not_found`), so **all** recovery + guidance is dropped from the document; +4. the content marker is named `@ag.file`; +5. `message` is optional. + +One deviation, deliberate. Assumption 3 says "retryable errors", but the engine classes +`invalid_operation` as **non**-retryable, and `invalid_operation` is what a rename hits +("`replace_item` must keep the key"). An agent can absolutely fix that by sending +`remove_item` plus `add_item`, so the harness attaches the guidance regardless of the flag. +See finding F.5.3. + +## F.2 The v3 document + +1,545 bytes, 392 tokens (cl100k). Source: `model-usability/instructions/v3.md`. + +```markdown +Commit a change to this agent's own configuration. + +Send `workflow_revision` with `base_revision_id` (the `revision_id` you read) and +`delta`. `delta` holds `operations`; they run in order, and if one fails nothing is +committed. + +TARGET: an array of segments from the configuration root. A string segment names an +object field. An object segment `{"list": L, "key": K}` names one entry of list L and +stands in place of L's name. Keyed lists: skills, mcps, tools (by name), files (by path). + + ["parameters","agent",{"list":"skills","key":"release-qa"}, + {"list":"files","key":"checklist.md"},"content"] + +OPERATIONS: +- `set` replace one field (needs `value`) +- `merge` deep-merge an object into one field (needs `value`) +- `remove` delete one field +- `edit_text` replace exact substrings in one string field (needs `edits`) +- `add_item` append to a list; target ends with the list name (needs `value`) +- `replace_item` replace one entry; target ends with a selector (needs `value`) +- `remove_item` delete one entry; target ends with a selector + +`edits` is a list of `{old_text, new_text}`. `old_text` must occur exactly once and match +character for character, line breaks included. Copy it from the configuration you read; never +retype it from memory. + +For a workspace file's content, write `{"@ag.file": ""}` where the string would go: + + {"operation":"add_item","target":["parameters","agent","skills"], + "value":{"name":"pdf-tools","description":"Make PDFs.", + "body":{"@ag.file":"imports/pdf-tools/SKILL.md"}}} +``` + +Sizes for comparison: v0 2,860 B / 709 tok, v1 2,593 B / 644 tok, v2 3,224 B / 798 tok, +**v3 1,545 B / 392 tok**. + +## F.3 Results: v2+L versus v3+fixes + +| Task | Haiku v2+L | Haiku v3+fixes | DS v2+L | DS v3+fixes | +|---|---|---|---|---| +| a edit one instruction sentence | 5/5 | 5/5 | 5/5 | 4/5 | +| b change one line in a skill body | 5/5 | 5/5 | 5/5 | 5/5 | +| c add one tool by name | 5/5 | 5/5 | 5/5 | 5/5 | +| d remove one MCP server | 5/5 | 5/5 | 5/5 | 5/5 | +| e add a skill from workspace files | 5/5 | 5/5 | 5/5 | 5/5 | +| f conflict, then retry on the new head | 5/5 | 5/5 | 5/5 | 5/5 | +| g ambiguous anchor, then retry | 5/5 | 5/5 | 5/5 | 5/5 | +| h wrong folder, then correct the path | 5/5 | 5/5 | 4/5 | 5/5 | +| i rename a skill, keeping its content | 5/5 | 5/5 | 5/5 | 5/5 | +| j edit a line inside a bundled file | 5/5 | 5/5 | 5/5 | 5/5 | +| k three changes in one commit | 5/5 | 5/5 | 5/5 | 5/5 | +| **all** | **55/55** | **55/55** | **54/55** | **54/55** | + +Identical totals. DeepSeek traded one task for another (it lost an `a`, it gained an `h`), +which at five trials per cell is noise. Both v3 arms recovered on every f, g and h trial. + +Input tokens fell with the document: Haiku 200,593 on v2+L to **179,952** on v3; DeepSeek +173,647 to **150,387**. Roughly 11% and 13% per task, which is the instruction saving +showing up once per turn. + +**The two tasks the v2 document taught explicitly, and v3 does not, both stayed at 5/5.** +Task i (rename) relies entirely on the engine's `invalid_operation` message plus its +`next_step`; task j (nested selectors) relies on the one positive example. Neither needed +its own sentence. + +## F.4 What the saving actually cost: the wrapper now does the teaching + +This is the finding that matters. I replayed every target the models sent and counted how +many the wrapper had to repair. + +| Arm | targets repaired | which mistake | +|---|---|---| +| Haiku v2+L | 0 / 80 | — | +| DeepSeek v2+L | 4 / 84 | 3 repeated list name, 1 named the key field | +| Haiku v3+fixes | **10 / 85** | 10 repeated list name, 0 named the key field | +| DeepSeek v3+fixes | **7 / 84** | 7 repeated list name, 0 named the key field | + +Under v2, the WRONG/WRONG/RIGHT block meant Haiku never made the mistake — zero repairs. +Under v3 it makes it on 12% of targets, and the wrapper silently fixes them. The block and +the normalizer are **substitutes, not complements**: the work moved from the document to +the code. v3's 100% depends on assumption 1 being real. Ship v3's wording without the +wrapper fix and roughly one operation in eight breaks. + +The rename did better than that. Both models used `{"list": ...}` on 100% of selectors — +40/40 for Haiku, 39/39 for DeepSeek, zero uses of `field` — and **the "named the key +field" mistake disappeared completely**. That mistake was `{"field": "name", ...}`, caused +by `field` reading as "which field identifies the entry". Calling it `list` removes the +ambiguity outright, with no wrapper support and no document sentence. It is the cheapest +fix in the whole spike. + +So of the two selector mistakes: renaming to `list` kills one for free; the other needs +either three lines of document or the normalizer, and the normalizer is cheaper per caller. + +## F.5 Three smaller results + +### F.5.1 Optional `message` is not enough — it has to go + +DeepSeek's single v3 failure is the same mode as before, on task a: + +``` +..."new_text": "Run the release-qa skill when the suite is\nunavailable."}]}]}, +"message": "Replace manual fallback with release-qa skill manual fallback with +release-qa skill instruction\"}}"} +``` + +`message` was **optional** in this arm and the model volunteered one anyway, then +degenerated inside it and lost a delta that was otherwise complete. Assumption 5 as stated +does not fix the failure it was meant to fix. If we want it fixed, `message` has to leave +the model-facing schema entirely and be derived server-side from the operations. + +### F.5.2 Errors teach as well as the document did, when they carry a next step + +Every recovery task hit 5/5 for both models with zero recovery guidance in the document. +Task g reached 5/5 **first-call** — the model picks a unique anchor without ever being told +to, because the target example shows it what a real anchor looks like. Assumption 3 holds: +guidance belongs in the error, where it is read at the moment it applies, not in a document +read once per turn. + +### F.5.3 `invalid_operation` is misclassified as non-retryable + +Contract section 10 marks `invalid_operation` not retryable. But it is the code a rename +gets, and a rename is fixable — send `remove_item` then `add_item`. The harness had to +attach guidance in spite of the flag for task i to work. A real agent that honors +`retryable: false` and stops would fail every rename. **Recommendation: split the code, or +reclassify it retryable.** A shape error the agent can restructure is not the same as a +policy refusal it cannot. + +## F.6 Verdict on minimum viable instruction size + +**Ship v3: about 1.5 KB / 400 tokens is the floor, and it is a real floor, not a squeeze.** +Every remaining sentence earns its place — cutting further would mean cutting an operation +line or the exact-copy rule, both of which section 4's ablation showed are load-bearing. + +Three conditions attach to that number, in order of how much they cost if skipped: + +1. **The wrapper must normalize the repeated list name.** Not optional. It absorbs 12% of + Haiku's targets under v3. +2. **Errors must carry a next-step sentence and the enriched content.** This is what buys + the removal of all recovery guidance, roughly a third of v2's bytes. +3. **Rename the selector key to `list`.** Free, and it removes an entire failure mode. + +What this does *not* say: it does not say instructions do not matter. The v0-to-v2 range in +section 2 is 34% to 96% on identical code. It says that once the interface stops surprising +the model — one unambiguous selector name, a wrapper that forgives the predictable slip, +and errors that say what to do next — the document has much less left to explain. The +budget moved; it did not vanish. Spend it on the target grammar and the exact-copy rule, +and let the errors do the rest. + +Method note: v3 was run in one arm, with all five assumptions on together. The per-assumption +attribution in F.4 and F.5 comes from replaying the trials, not from separate arms, so the +individual contributions of assumptions 2, 3 and 4 are inferred rather than isolated. +Assumption 1 is the exception: v2+L versus v3+fixes isolates it directly, because the +document is the only other thing that changed. diff --git a/docs/design/agent-config-editing/spikes/model-usability/harness.py b/docs/design/agent-config-editing/spikes/model-usability/harness.py index e5dac632ec..6aa192e89b 100644 --- a/docs/design/agent-config-editing/spikes/model-usability/harness.py +++ b/docs/design/agent-config-editing/spikes/model-usability/harness.py @@ -22,6 +22,10 @@ TOOL_NAME = "commit_workflow_revision" MARKER = "$content_from" +MARKER_V3 = "@ag.file" +MARKERS = (MARKER, MARKER_V3) + +V3_SURFACE = False # schema advertises {"list": ...} and @ag.file, and message is optional # -------------------------------------------------------------------------------------- @@ -51,19 +55,20 @@ def to_detail(self) -> Dict[str, Any]: def resolve_markers(value: Any) -> Any: """Replace every ``{"$content_from": path}`` object with the file's text.""" if isinstance(value, dict): - if set(value) == {MARKER}: - path = value[MARKER] + found = [m for m in MARKERS if m in value] + if found and set(value) == {found[0]}: + path = value[found[0]] if not isinstance(path, str) or not path: raise RunnerRefusal( "source_invalid", - f"'{MARKER}' needs a non-empty workspace path.", + f"'{found[0]}' needs a non-empty workspace path.", retryable=False, ) return _materialize_file(path) - if MARKER in value and len(value) > 1: + if found and len(value) > 1: raise RunnerRefusal( "source_invalid", - f"An object that carries '{MARKER}' must carry nothing else. " + f"An object that carries '{found[0]}' must carry nothing else. " f"Found: {sorted(value)}.", retryable=False, ) @@ -219,6 +224,14 @@ def resolve_value_from(delta: Any) -> Any: _COLLECTION_OF_KEY_FIELD = {"path": "files"} +def _canonical_selector(segment: Any) -> Any: + """Accept ``{"list": L, "key": K}`` and hand the engine its ``{"field": ...}`` form.""" + if isinstance(segment, dict) and "list" in segment and "field" not in segment: + out = {"field": segment["list"], "key": segment.get("key")} + return out if out["key"] is not None else segment + return segment + + def normalize_target(segments: Any) -> Any: """Repair the two mistakes the trials showed, without changing anything else. @@ -230,7 +243,7 @@ def normalize_target(segments: Any) -> Any: if not isinstance(segments, list): return segments out: List[Any] = [] - for segment in segments: + for segment in [_canonical_selector(x) for x in segments]: if ( isinstance(segment, dict) and set(segment) == {"field", "key"} @@ -254,7 +267,9 @@ def normalize_target(segments: Any) -> Any: def normalize_delta(delta: Any) -> Any: - if not LENIENT or not isinstance(delta, dict): + if not isinstance(delta, dict): + return delta + if not LENIENT and not V3_SURFACE: return delta operations = delta.get("operations") if not isinstance(operations, list): @@ -302,6 +317,31 @@ def _closest_fragments(text: str, old_text: str, limit: int = 3) -> List[str]: return seen +# One instruction sentence per retryable reason code. The v3 document drops all recovery +# guidance, so the errors themselves must carry it. +NEXT_STEP = { + "target_not_found": "Check the target against the configuration you read. A " + "{list, key} selector stands in place of the list's own name, so the list name must " + "not appear as a segment before it.", + "target_type_mismatch": "Check the target. A {list, key} selector stands in place of " + "the list's own name; do not write the list name and then a selector.", + "item_not_found": "Read the list and use one of the keys it actually holds.", + "item_already_exists": "Use replace_item to change the existing entry, or pick a new " + "key.", + "duplicate_item_key": "Two entries share this key. Remove one before editing this " + "list.", + "text_not_unique": "Add surrounding lines to the anchor until it occurs exactly once.", + "text_edits_overlap": "Two anchors share characters. Merge them into one edit.", + "no_change": "The edits change nothing. Check the anchor and the replacement.", + "empty_old_text": "old_text must not be empty.", + "unkeyed_collection": "Only skills, mcps, tools, and files can be addressed by key.", + "item_key_undefined": "The value needs a 'name' the entry can be addressed by.", + "invalid_operation": "Fix the shape of the operation and send it again.", + "final_validation_failed": "The finished configuration is not valid. Read the issues " + "and correct the change.", +} + + def enrich_error( detail: Dict[str, Any], delta: Any, config: Dict[str, Any] ) -> Dict[str, Any]: @@ -310,6 +350,11 @@ def enrich_error( return detail reason = detail.get("reason") or {} code = reason.get("code") + # Guidance is attached even when the engine calls the code non-retryable. + # `invalid_operation` covers the rename case, which the agent CAN fix by sending + # remove_item + add_item, so withholding the sentence there would be wrong. + if code in NEXT_STEP: + reason["next_step"] = NEXT_STEP[code] if code == "text_not_found": index = detail.get("operation_index") try: @@ -441,9 +486,9 @@ def run_commit( { "type": "object", "additionalProperties": False, - "required": ["field", "key"], + "required": ["SELECTOR_KEY", "key"], "properties": { - "field": {"type": "string", "minLength": 1}, + "SELECTOR_KEY": {"type": "string", "minLength": 1}, "key": {"type": "string", "minLength": 1}, }, }, @@ -586,6 +631,13 @@ def _member(operation: str, *, target_tail: str, value: bool, edits: bool) -> di def tool_schema(*, union: bool = False) -> Dict[str, Any]: operation = _UNION_OPERATION if union else _FLAT_OPERATION + selector_key = "list" if V3_SURFACE else "field" + operation = json.loads( + json.dumps(operation).replace("SELECTOR_KEY", selector_key) + ) + required = ["base_revision_id", "delta"] + if not V3_SURFACE: + required.append("message") return { "type": "object", "additionalProperties": False, @@ -594,7 +646,7 @@ def tool_schema(*, union: bool = False) -> Dict[str, Any]: "workflow_revision": { "type": "object", "additionalProperties": False, - "required": ["base_revision_id", "message", "delta"], + "required": required, "properties": { "base_revision_id": { "type": "string", From 1bb878e6f31473e7736e501258b8ce9b9919180a Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Wed, 5 Aug 2026 11:35:36 +0200 Subject: [PATCH 16/36] docs(design): briefing: clarify the live-tool-updates consequence sentence --- docs/design/agent-config-editing/BRIEFING.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/design/agent-config-editing/BRIEFING.md b/docs/design/agent-config-editing/BRIEFING.md index 2ba0651cec..d451144caa 100644 --- a/docs/design/agent-config-editing/BRIEFING.md +++ b/docs/design/agent-config-editing/BRIEFING.md @@ -146,8 +146,11 @@ only because OUR tool server never announces that it supports the capability (on missing flag). Pi has live APIs to register and hide tools mid-session; they are unreachable only because WE deliver Pi's tool list in a process environment variable, which is read once at startup and can never change. Only Codex genuinely bakes its -tool configuration at session creation and needs a reopen. Consequence: live tool -updates are mostly our work, not the harnesses' work. +tool configuration at session creation and needs a reopen. Consequence: we feared +live tool updates would need changes in the harnesses, which are other people's +software we cannot patch. They do not. Every missing piece is in our own +repository, and we can ship it whenever we choose. Only Codex stays on a session +reopen. **Finding 2: the approval pause could leave a session running stale instructions.** Context: when a run stops and waits for your approval, the session is parked. The From 0adab24f739292c594314ec476be66a5208dc565 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Wed, 5 Aug 2026 11:42:40 +0200 Subject: [PATCH 17/36] docs(design): Mahmoud's review decisions: uniform tool reopen, per-class match tolerance, uncommittable build kit --- docs/design/agent-config-editing/decisions.md | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/docs/design/agent-config-editing/decisions.md b/docs/design/agent-config-editing/decisions.md index 48283f367c..9fe75907ec 100644 --- a/docs/design/agent-config-editing/decisions.md +++ b/docs/design/agent-config-editing/decisions.md @@ -87,6 +87,32 @@ reasoning is in `spikes/engine-spike.md` (D1-D33, O1-O12) and `spikes/runner-spi pretending to secure the channel: worst case is a stale model-visible catalog, never a privilege escalation. adapter-matrix.md §4.3. +## Decisions from Mahmoud's PR review (5 August) + +- **Tool-list changes route to session reopen on EVERY harness in v1.** Uniform + behavior beats a per-harness split where Pi and Claude go live and Codex breaks + the pattern. The adapter capability matrix stays in the design (flipping one + harness to live later is a one-line capability change), but v1 sets all three to + reopen-session. Consequence: the untrusted-acknowledgement machinery, the Pi + specs-file channel, and the shim listChanged work all leave v1 scope and move to + the backlog. Codex upstream: a quick source check runs now; filing an upstream + issue needs Mahmoud's explicit approval first. +- **Match tolerance differs by what the text is.** Prose fields (the instructions + document, a skill body, descriptions) match exact-first, then retry with + normalized quotes, dashes, and whitespace; a normalized match must still be + unique, and the response reports that normalization was used. Script and file + contents (skill files, code-tool scripts) match exact only, because bytes are + meaning there. This also settles open decision 1's tension: stored bytes stay + exact (option A), and the prose-side tolerance lives in matching, not in storage. +- **The build kit must be uncommittable.** Today's bug: agents sometimes commit + the injected playground tools into their configuration. Three guards: (1) + read_config reads the STORED revision, which never contains the injected kit, so + reads are clean by construction; (2) the commit wrapper REJECTS any tool entry + of the platform kind with a retryable error naming the entries ("these are + playground tools, not part of your configuration; remove them and retry"); (3) + one line in the tool instructions says the same up front. Rejection is chosen + over silent stripping because the spike showed errors teach. + ## Arbitrations after the model-usability spike (team lead + Mahmoud, 5 August) - **The inline marker replaces the operation-level source, and it is named From b692353277b2e31bf7c5991422afec6e3fbf2269 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Wed, 5 Aug 2026 11:50:33 +0200 Subject: [PATCH 18/36] docs(design): review round: refactor steps detailed, reconciliation directives, .agenta-imports rename, folder policy dissolved by @ag.file, v3 assets, codex upstream check --- docs/design/agent-config-editing/BRIEFING.md | 160 ++++++++++++++---- docs/design/agent-config-editing/decisions.md | 39 ++++- .../spikes/model-usability/instructions/v3.md | 31 ++++ .../spikes/model-usability/results.tar.gz | Bin 38306 -> 46356 bytes .../spikes/model-usability/run.py | 3 + .../spikes/model-usability/table.py | 2 + .../spikes/runner-spike.md | 133 +++++++++++++++ 7 files changed, 335 insertions(+), 33 deletions(-) create mode 100644 docs/design/agent-config-editing/spikes/model-usability/instructions/v3.md diff --git a/docs/design/agent-config-editing/BRIEFING.md b/docs/design/agent-config-editing/BRIEFING.md index d451144caa..3fcdc2a47c 100644 --- a/docs/design/agent-config-editing/BRIEFING.md +++ b/docs/design/agent-config-editing/BRIEFING.md @@ -273,47 +273,149 @@ survives only as a fast "nothing changed at all" shortcut. environment owns it; teardown reasons are precise; revision numbers no longer count as changes. -**What comes next, in order (each its own slice):** - -1. **Move the decision logic out of the web server file** into one coordinator, with - no behavior change. Today the reuse decisions live inside the HTTP server code, - which makes every later step risky. (Slice S6.) -2. **Shadow routing.** The new compare-and-decide logic runs alongside the old one, - only logging what it WOULD have decided. We watch for disagreements before - trusting it. (Also S6.) -3. **Split the big environment file into lifecycle units** (sandbox, runtime, mount, - workspace, harness session), still with no behavior change. (S7a.) -4. **Turn on the cheap routes:** rewrite instructions and skills in place, set the - model on the live session. (S7b.) -5. **Turn on live tool updates** where finding 1 showed they are reachable, per - harness, with the acknowledgement rules. (S7c, after its foundation step S7c0.) -6. **Session reopen for MCP-server changes, and credential refresh** so a rotated - API key on Daytona no longer rebuilds the sandbox. (S7d, S7e.) +**What comes next, in order. Each step is one slice, and each is explained here in +full.** + +### Step 1 (slice S6): move the decision logic out of the web server file + +The file is `services/runner/src/server.ts`. It is the HTTP server: it accepts the +run request, checks the caller, and streams the answer back. But today it ALSO +contains the session reuse policy, about 600 lines of it. When a request arrives, +this code looks up the parked session, compares the configuration checksum, the +conversation history, the credentials, and the mount expiry, and then decides: reuse +warm, resume an approval, or rebuild cold. It also re-parks the session after the +turn and picks the eviction reason. + +That placement is the problem. Transport code and policy code live in one file, so +the policy cannot be tested without faking the HTTP layer, and every policy change +risks the server. The stale-instructions bug lived exactly in this mixed zone. + +The step: create `lifecycle/session-coordinator.ts` and MOVE the decision code into +it, unchanged. The server keeps HTTP, authentication, and request decoding, and +makes one call: run this request through the coordinator. No behavior changes. Every +existing test must still pass. This step is pure preparation: it makes the next +steps safe. + +### Step 2 (also S6): shadow routing + +The new decision logic works differently from the old one. It splits the request +into facets (model, instructions, skills, tools, MCP servers, credentials), compares +each facet of the DESIRED state against the APPLIED state the environment records, +and produces a plan: a small, readable list of actions, for example "refresh two +workspace files, keep everything else." This is the Terraform idea: plan first, +apply second. + +In this step the new logic runs in SHADOW: on every request it computes its plan and +writes it to the log, and then the OLD logic makes the real decision, exactly as +today. We then compare: when the old logic rebuilt and the plan says "one file +refresh would have been enough", that is a logged disagreement. We flip to the new +logic only after production traffic shows the plans are right. Zero risk while we +learn. + +### Step 3 (slice S7a): split the environment file into lifecycle units + +The file is `services/runner/src/engines/sandbox_agent/environment.ts`, more than a +thousand lines. Today one function does the whole cold start in a fixed order: +create or reconnect the sandbox, push the harness assets, attach the durable mounts, +write the workspace files, probe capabilities, open the harness session. One +function also destroys all of it. + +The step: split it into five units, one per lifecycle, still with no behavior +change: sandbox (create, reconnect, stop, destroy), runtime (the agent daemon, its +process environment, its credentials), mount (attach, renew leases), workspace +(write and refresh the instruction and skill files, including deletions), and +harness session (open, load, reopen, close). The environment file becomes a thin +composer that calls the units in order. + +Why this must come before step 4: an in-place update means calling ONE unit alone +("refresh the workspace files, touch nothing else"). While the cold start is one +function, that is impossible; you can only run all of it. + +### Step 4 (slice S7b): turn on the cheap routes + +With the plan from step 2 and the units from step 3, the first in-place routes +switch on: + +- Instructions changed: the workspace unit rewrites `AGENTS.md` (or `CLAUDE.md`) in + the live sandbox. The session survives. +- Skills changed: the workspace unit refreshes the skill folders, including + deleting folders for removed skills (today's code never deletes). +- Model changed: the session unit calls the existing set-model API + (`services/runner/src/engines/sandbox_agent/model.ts`) before the next turn. + +This is the step where the 12.5-second penalty for a one-word edit dies. Reminder of +an accepted behavior: a live harness does not re-read the instruction file on its +own; we update the file, and the harness reads it when it reads it. That was your +call, and it stands. + +### Step 5 (slices S7c0, then S7c): tool changes + +Your decision applies: in v1, a tool-list change gets a SESSION REOPEN on every +harness, uniformly. A reopen closes and reopens the harness session on the SAME +sandbox and reloads the native conversation where the harness supports it. It costs +seconds, not the 12.5-second rebuild, and it behaves the same on Pi, Claude, and +Codex. + +S7c0 is the foundation fix that must come first: today the turn code +(`services/runner/src/engines/sandbox_agent/run-turn.ts`) mixes the tool +specifications captured at session start with the callback settings of the current +turn. Those two must carry ONE shared generation number, so the tools the model sees +and the tools that execute can never drift apart. + +The live-update machinery for Pi and Claude (the notification flag in our tool +server, the file-based delivery for Pi, the acknowledgement rules) is shelved: not +in v1, kept in the backlog with named insertion points, cheap to enable per harness +later because apply-live stays a declared capability in the adapter table. + +### Step 6 (slices S7d, S7e): MCP reopen, and credential refresh + +S7d: a change to the MCP SERVER LIST has no live path on any harness (we verified: +the session API has no call for it, and the Claude adapter tears the session down +itself when the list changes). So it routes to session reopen, with one fix: the +reopen must positively verify that the native conversation history actually loaded. +Today's check compares only a session id, which can claim continuity that did not +happen. + +S7e: today Daytona bakes credentials and environment values into the sandbox at +creation, and the creation checksum treats ANY difference as "different sandbox", +so a rotated API key destroys and recreates the whole thing. The step splits +identity from state: the image, snapshot, and provider define the sandbox and still +rebuild; credentials and timers become refreshable state, delivered to the running +sandbox, at most restarting the agent daemon inside it. A rotated key then costs +seconds and the sandbox survives. ## 8. The implicit decisions we made, with context Decisions the team made during design without asking you, each recorded and each reversible by a comment on this file. -1. **Imports come only from the `imports/` folder.** Context: the first draft allowed +1. **Imports come only from a designated folder, named `.agenta-imports/`.** + (Renamed by Mahmoud during review: a dot-folder, so the Files drawer's existing + internal-path filter hides it and non-technical users never see a system folder; + the model finds it through the instructions and the path errors, which is what + it actually reads.) Context: the first draft allowed any path in the workspace, with the approval card as the control. The reviewer pushed back: the workspace also holds files the agent created for other reasons, possibly secrets, and a human skimming a manifest is not a security boundary. A dedicated folder makes intent explicit: things placed there are meant to be committed. -2. **A folder with unsupported files rejects whole, by default.** Context: skill +2. **Every file reference stands alone, and a bad file fails the whole commit.** + (Simplified by the `@ag.file` redesign: there is no folder source anymore, so + the old "folder with unsupported files" policy dissolved.) Context: skill content is stored as text, so a PNG or a compiled binary cannot be stored - faithfully today. The first draft silently dropped such files and committed the - rest. That means the user believes the skill is complete when it is not. Now the - import fails with a clear reason, and committing with omissions requires an - explicit opt-in that the approval card displays. -3. **Executable permission is four separate things.** Context: "this file is - executable" was one boolean doing four jobs. Now: the file's own mode bit is - data (always recorded); whether the import may CONTAIN executables is an - ephemeral grant the approver sees; whether the stored skill may USE them is a - persisted capability, default off; and whether the sandbox actually allows - execution stays platform policy. You can import bits faithfully without granting - execution; you cannot grant execution for bits you refused to import. + faithfully today. Under `@ag.file`, each reference is one file. A file that is + binary, oversized, or missing fails its own marker with a clear reason, and + because a commit applies all operations or none, nothing partial ever commits. + The old opt-in for committing with omissions is gone; if the agent wants to skip + a file, it simply does not reference it, which is visible in the approval card. +3. **Executable permission is authored explicitly, never derived.** (Also + simplified by the `@ag.file` redesign: the import-grant layer belonged to the + folder source and dissolved with it.) Context: `@ag.file` resolves CONTENT + only. Whether a skill file is executable is a normal field the agent writes + (`executable: true`) and the approval card must show; whether the stored skill + may use executables is the existing configuration field, default off, also + agent-written and card-visible; whether the sandbox actually executes anything + stays platform policy. Nothing is ever inferred from a file's mode bits. 4. **After a cold resume, an approved import asks again.** Context: the approved frozen bytes live with the parked session. If the session dies before execution (crash, timeout), the bytes are gone. Reading the folder again would commit diff --git a/docs/design/agent-config-editing/decisions.md b/docs/design/agent-config-editing/decisions.md index 9fe75907ec..78b388b67f 100644 --- a/docs/design/agent-config-editing/decisions.md +++ b/docs/design/agent-config-editing/decisions.md @@ -87,6 +87,25 @@ reasoning is in `spikes/engine-spike.md` (D1-D33, O1-O12) and `spikes/runner-spi pretending to secure the channel: worst case is a stale model-visible catalog, never a privilege escalation. adapter-matrix.md §4.3. +## Design directives for the runner refactor (Mahmoud, 5 August) + +- **Study the Kubernetes and Terraform reconciliation patterns before slice S6 + starts, and borrow their naming and construction where they fit.** Not their + complexity. Specifically map: spec/status to our desired/applied state; + Kubernetes' generation and observedGeneration to our catalog generation and + acknowledgement; Terraform's plan-then-apply to our router producing an explicit + Plan object that can be logged and tested without executing (shadow routing then + falls out as "plan without apply"); level-triggered reconciliation (decide from + current state, never from missed events) as a stated invariant, which our + fingerprint-travels-with-the-request property already satisfies. +- **The refactor must make per-harness live routes cheaply EXPRESSIBLE without + implementing them in v1.** Apply-live stays a first-class action kind in the + action vocabulary and a declared capability in the adapter port; v1 sets every + harness's tool-catalog capability to reopen-session. The shelved machinery (the + untrusted acknowledgement, the Pi specs-file channel, the shim listChanged flag) + is recorded in the backlog with its insertion points named, so enabling one + harness later is a capability flip plus the shelved component, not a redesign. + ## Decisions from Mahmoud's PR review (5 August) - **Tool-list changes route to session reopen on EVERY harness in v1.** Uniform @@ -160,10 +179,22 @@ reasoning is in `spikes/engine-spike.md` (D1-D33, O1-O12) and `spikes/runner-spi ## Settled by the contracts (no longer open) -- Binary and unsupported files reject the whole import by default; `on_unsupported: - "omit"` is the explicit opt-in. (Was open call 5.) -- Imports come from the designated `imports/` root, not the whole workspace. (Was open - call 6.) +- Binary and unsupported files: superseded by the `@ag.file` redesign (Mahmoud, + 5 August). There is no folder source, so there is no folder policy. Each + `@ag.file` reference is one file; an unsupported file fails its own marker with + a clear reason, and the all-or-nothing commit guarantees nothing partial ever + lands. `on_unsupported`, `on_executable`, and `persist_executable_capability` + are all removed; the executable flag and the skill's executable capability are + ordinary agent-authored fields that the approval card must display. (Was open + call 5 and the four-layer split.) +- Imports come from a designated root folder, not the whole workspace. (Was open + call 6.) **Amended by Mahmoud, 5 August: the folder is `.agenta-imports/`, not + `imports/`.** Two reasons: a dot-folder stays out of sight in shells by default, + and the Files drawer's existing internal-path filter already hides the + `.agenta-*` prefix from listings, so non-technical users never see a confusing + system folder, with zero new UI work. The instructions and every path error name + the folder explicitly, which the usability spike showed is what the model + actually reads. - Cold resume refuses the old approval and asks again; frozen bytes are not persisted durably. (Was open call 9.) - Pi tool removal hides the tool AND drops the runner execution binding; hidden-only diff --git a/docs/design/agent-config-editing/spikes/model-usability/instructions/v3.md b/docs/design/agent-config-editing/spikes/model-usability/instructions/v3.md new file mode 100644 index 0000000000..2b43ed187d --- /dev/null +++ b/docs/design/agent-config-editing/spikes/model-usability/instructions/v3.md @@ -0,0 +1,31 @@ +Commit a change to this agent's own configuration. + +Send `workflow_revision` with `base_revision_id` (the `revision_id` you read) and +`delta`. `delta` holds `operations`; they run in order, and if one fails nothing is +committed. + +TARGET: an array of segments from the configuration root. A string segment names an +object field. An object segment `{"list": L, "key": K}` names one entry of list L and +stands in place of L's name. Keyed lists: skills, mcps, tools (by name), files (by path). + + ["parameters","agent",{"list":"skills","key":"release-qa"}, + {"list":"files","key":"checklist.md"},"content"] + +OPERATIONS: +- `set` replace one field (needs `value`) +- `merge` deep-merge an object into one field (needs `value`) +- `remove` delete one field +- `edit_text` replace exact substrings in one string field (needs `edits`) +- `add_item` append to a list; target ends with the list name (needs `value`) +- `replace_item` replace one entry; target ends with a selector (needs `value`) +- `remove_item` delete one entry; target ends with a selector + +`edits` is a list of `{old_text, new_text}`. `old_text` must occur exactly once and match +character for character, line breaks included. Copy it from the configuration you read; never +retype it from memory. + +For a workspace file's content, write `{"@ag.file": ""}` where the string would go: + + {"operation":"add_item","target":["parameters","agent","skills"], + "value":{"name":"pdf-tools","description":"Make PDFs.", + "body":{"@ag.file":"imports/pdf-tools/SKILL.md"}}} diff --git a/docs/design/agent-config-editing/spikes/model-usability/results.tar.gz b/docs/design/agent-config-editing/spikes/model-usability/results.tar.gz index 904309a813def64c08c4df1073eebb1ed57c3684..9ebc7d3c9a60215e86d39822672a7a5b63b57f3e 100644 GIT binary patch literal 46356 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 P50IJDp%@T&1Q7oRtKV?> literal 38306 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 None: parser.add_argument("--workers", type=int, default=3) parser.add_argument("--rich-errors", action="store_true") parser.add_argument("--lenient", action="store_true") + parser.add_argument("--v3-surface", action="store_true") parser.add_argument("--out", required=True) args = parser.parse_args() load_env() harness.RICH_ERRORS = args.rich_errors or args.lenient harness.LENIENT = args.lenient + harness.V3_SURFACE = args.v3_surface instructions = (HERE / "instructions" / f"{args.instructions}.md").read_text() schema = harness.tool_schema(union=args.union_schema) @@ -390,6 +392,7 @@ def work(item: Tuple["T.Task", int]) -> Dict[str, Any]: record["union_schema"] = args.union_schema record["rich_errors"] = args.rich_errors record["lenient"] = args.lenient + record["v3_surface"] = args.v3_surface with lock: handle.write(json.dumps(record, ensure_ascii=False) + "\n") handle.flush() diff --git a/docs/design/agent-config-editing/spikes/model-usability/table.py b/docs/design/agent-config-editing/spikes/model-usability/table.py index c96d31fbdc..11f2f6cfc7 100644 --- a/docs/design/agent-config-editing/spikes/model-usability/table.py +++ b/docs/design/agent-config-editing/spikes/model-usability/table.py @@ -22,10 +22,12 @@ ("haiku", "v1", False, "Haiku v1"), ("haiku", "v2", False, "Haiku v2"), ("haiku", "v2", True, "Haiku v2+L"), + ("haiku", "v3", True, "Haiku v3+fixes"), ("deepseek", "v0", False, "DS v0"), ("deepseek", "v1", False, "DS v1"), ("deepseek", "v2", False, "DS v2"), ("deepseek", "v2", True, "DS v2+L"), + ("deepseek", "v3", True, "DS v3+fixes"), ] rows = [] diff --git a/docs/design/agent-config-editing/spikes/runner-spike.md b/docs/design/agent-config-editing/spikes/runner-spike.md index 5732915c75..d3d5e0c3e1 100644 --- a/docs/design/agent-config-editing/spikes/runner-spike.md +++ b/docs/design/agent-config-editing/spikes/runner-spike.md @@ -460,3 +460,136 @@ messages. 9. **Should `remove_item` for a skill also clean the workspace?** Out of scope here, but the lifecycle doc's warning about `prepareWorkspace` not removing vanished skill directories (`workspace.ts:56`) is the same problem seen from the other end. + +--- + +## Codex upstream check + +Follow-up on open question 8. Read from **source**, not from the shipped binaries. + +Sources read: + +- `github.com/agentclientprotocol/codex-acp` at `efa3789` (HEAD, 2026-08-02; npm `1.1.9`, we pin + `1.1.7` — the relevant code is unchanged between them). +- `github.com/openai/codex` at `fcc4ca5` (main, 2026-08-05). We run `@openai/codex ^0.145.0`, whose + tags are `rust-v0.145.0-alpha.*` off this same line. + +### Verdict: **feasible with a small upstream change, in `openai/codex` — not in `codex-acp`** + +The pieces are almost all there and the last hop is missing. Precisely: + +1. **The core does receive the notification.** `codex-rs/rmcp-client/src/logging_client_handler.rs:86` + implements rmcp's `ClientHandler::on_tool_list_changed`. That is the source of the + `notifications/tools/list_changed` and `ToolListChangedNotification` strings we found in the + binary. The whole body is `info!("MCP server tool list changed")`. It logs and returns. Its + siblings `on_resource_list_changed` (line 82) and `on_prompt_list_changed` (line 88) are the same + one-line stubs. **There is no live handler; there is a live log statement.** + +2. **The core already has full mid-session MCP refresh machinery.** `codex-rs/core/src/session/mcp.rs` + has `mark_mcp_runtime_dirty()` (line 274), `refresh_mcp_if_dirty()` (line 151) and + `refresh_mcp_servers_now()` (line 579). `refresh_mcp_if_dirty` is called at turn boundaries + (`core/src/codex_thread.rs:692,710`, `core/src/session/turn.rs:630`), so a session can and does + rebuild its MCP runtime and tool list **between turns without a session rebuild**. Auth changes, + plugin installs and skill MCP dependencies already drive it. + +3. **The app-server exposes a client-triggered reload.** `config/mcpServer/reload` + (`codex-rs/app-server-protocol/src/protocol/common.rs:1023`, handler + `app-server/src/request_processors/mcp_processor.rs:80`, implementation + `app-server/src/mcp_refresh.rs:9`). It takes **no params** (`Option<()>`, ts `undefined`) and + re-reads config from disk, then calls `thread.refresh_mcp_config(config)` for every live thread. + `load_latest_config_for_thread` uses `rebuild_preserving_session_layers` + (`app-server/src/config_manager.rs:158-171`), so an ACP-injected server is **not** wiped by a + reload. + +4. **But a reload would not help us**, and this is the part that decides the verdict. The refresh is + config-diff driven. In `codex-mcp/src/connection_manager.rs:337-362`, a server whose + `McpServerConnectionIdentity` (name + config + environment + runtime context) is unchanged has its + existing connection — and therefore its already-fetched tool view — **reused wholesale**, and the + loop `continue`s without re-listing. On top of that, `codex-mcp/src/tool_catalog_cache.rs` is a + process-scoped LRU keyed on the same identity with a **30-minute TTL** (lines 28-29, 74-95). Our + case is exactly the one this defeats: the server config never changes, only the tool list behind + it does. + +So wiring `on_tool_list_changed` straight to `mark_mcp_runtime_dirty()` is necessary but **not +sufficient** — the refresh would reuse the cached catalog and observe nothing. The upstream change +is two small parts in one repo: plumb the notification to an invalidation callback (the existing +`SendElicitation` callback in `rmcp-client/src/rmcp_client.rs` is the precedent for how to reach +out of the handler), and have that invalidation force a re-list for the one affected server, +bypassing the connection-reuse fast path and the catalog cache entry. + +**`codex-acp` needs no change for this.** The MCP client lives in the Rust core, so our notification +travels shim → core and never passes through the adapter. The adapter's own gaps are real but +secondary: it bakes `mcp_servers` into the per-thread config at `thread/start` +(`src/CodexAcpClient.ts:492-523`), it advertises `mcpCapabilities: { acp: false, http: true, sse: +false }` (`src/CodexAcpServer.ts:246-250`), and it has `config/mcpServer/reload` in its generated +app-server types (`src/app-server/ClientRequest.ts`) but never calls it. None of that matters until +the core acts on the notification. + +**Consequence for us: the Codex row of the Part 2 verdict table stands — "needs session reopen" +today** — but the reason is narrower and more fixable than we thought. It is one unwired callback +plus a cache bypass in `openai/codex`, not an architectural gap. + +**Workaround worth noting** (not tested): the connection-identity check keys on the server *config*. +Changing any part of it — a nonce env var on the stdio server, a query param on the HTTP URL — would +defeat the reuse check and force a reconnect plus a fresh `tools/list`. But there is no ACP method to +change a live session's `mcpServers`, and `config/mcpServer/reload` reads only from disk, so today +this is reachable only by writing `config.toml` and having a client call the reload — which the +adapter does not expose. + +### Draft upstream issue — DRAFT, NOT FILED, needs Mahmoud's approval + +Target repo: **`openai/codex`** (not `codex-acp`). + +````markdown +Title: MCP `notifications/tools/list_changed` is received but never refreshes the session's tool list + +### What we observed + +`ClientHandler::on_tool_list_changed` in `codex-rs/rmcp-client/src/logging_client_handler.rs` +logs the notification and returns: + +```rust +async fn on_tool_list_changed(&self, _context: NotificationContext) { + info!("MCP server tool list changed"); +} +``` + +Nothing downstream is invalidated, so a server that adds or removes tools mid-session is never +re-listed and the model keeps the tool set captured at session start. + +The refresh machinery this would need already exists: `Session::mark_mcp_runtime_dirty` / +`refresh_mcp_if_dirty` (`codex-rs/core/src/session/mcp.rs`) rebuild the MCP runtime at turn +boundaries, and the app-server exposes `config/mcpServer/reload`. The notification is simply not +wired to them. + +A plain wiring would not be enough on its own. In +`codex-rs/codex-mcp/src/connection_manager.rs`, a server whose `McpServerConnectionIdentity` +(name + config + environment + runtime context) is unchanged has its existing connection reused and +its tools are not re-listed, and `codex-rs/codex-mcp/src/tool_catalog_cache.rs` caches the catalog +under the same identity with a 30-minute TTL. Since a `tools/list_changed` notification arrives +with the server config unchanged by definition, both fast paths would suppress the refresh. + +Verified against `main` (`fcc4ca5`); we run `@openai/codex` 0.145.0 via an ACP adapter. + +### Use case + +We connect Codex to an MCP server whose tool list is generated and changes while a session is live: +tools appear and disappear in response to user action, without the server's configuration changing. +The server advertises `tools.listChanged` and emits `notifications/tools/list_changed`. Today the +only way for the model to see the new list is to end the session and start a new one, which loses +the conversation. + +### Ask + +Make `on_tool_list_changed` invalidate the notifying server's cached tool catalog and cause a +re-list on the next turn, bypassing the connection-reuse fast path and the catalog-cache entry for +that server only. Refreshing at the next turn boundary (rather than mid-turn) would fully solve our +case. + +If a push-driven refresh is not wanted, a client-triggered equivalent would also work: a way to +force a re-list for a named server on a live thread — for example params on +`config/mcpServer/reload`, or a new `mcpServer/tools/refresh` method — so an adapter can trigger it +on the client's behalf. + +Happy to prepare a PR if you can point at the shape you'd prefer. +```` From c1e40c9421d174e8c2325c8d004cc59b490339e0 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Wed, 5 Aug 2026 11:55:31 +0200 Subject: [PATCH 19/36] docs(design): decision 1 answered (exact bytes); rollout corrected to single-flag kill switch; open-issues log added --- docs/design/agent-config-editing/BRIEFING.md | 18 +++--- docs/design/agent-config-editing/decisions.md | 5 +- .../agent-config-editing/open-issues.md | 59 +++++++++++++++++++ docs/design/agent-config-editing/plan.md | 30 ++++++---- 4 files changed, 93 insertions(+), 19 deletions(-) create mode 100644 docs/design/agent-config-editing/open-issues.md diff --git a/docs/design/agent-config-editing/BRIEFING.md b/docs/design/agent-config-editing/BRIEFING.md index 3fcdc2a47c..8261e2ff71 100644 --- a/docs/design/agent-config-editing/BRIEFING.md +++ b/docs/design/agent-config-editing/BRIEFING.md @@ -448,13 +448,14 @@ reversible by a comment on this file. field checking for the old delta format would break shipped playbooks and stored callers that today send harmless extra fields. Old format keeps old tolerance; the new operations format rejects unknown fields from day one. -10. **Rollout is dark-first with a two-sided kill switch.** Context: naively shipping - the API first breaks old runners (they would forward unresolved imports). So: - API support ships disabled, then runner support disabled, then the catalog - starts advertising the new format. One flag turns it all off, enforced in BOTH - the API (rejects the new format) and the runner (refuses to read the workspace), - because a stale harness can still emit the new format after the catalog stops - advertising it. +10. **One feature flag as a kill switch; no staged rollout.** (Corrected after + Mahmoud's review: the earlier "dark-first deployment order" assumed components + deploy independently. In our stacks the API, the catalog, and the runner ship + together, in compose and in the cloud, so there is no mixed-version period to + sequence around.) What stays: one flag, read at request time by both the API + and the runner, turns the whole feature off per deployment. The legacy delta + form works on every version regardless, which also covers the minutes of a + cloud rolling deploy. ## 9. The open decisions (yours) @@ -479,6 +480,9 @@ a straight one, the match fails. **Recommendation: A.** One rule, no corruption risk, and the read-before-write loop makes the occasional failed match cheap. +**ANSWERED by Mahmoud, 5 August: Option A.** Recorded in decisions.md; slices S1a +and S1b are unblocked. + ### Decision 2: the unique-name rule Named editing needs unique names. Today a config with two skills named `notes` saves diff --git a/docs/design/agent-config-editing/decisions.md b/docs/design/agent-config-editing/decisions.md index 78b388b67f..d4bcf272da 100644 --- a/docs/design/agent-config-editing/decisions.md +++ b/docs/design/agent-config-editing/decisions.md @@ -66,7 +66,10 @@ reasoning is in `spikes/engine-spike.md` (D1-D33, O1-O12) and `spikes/runner-spi ## Product calls confirmed by Mahmoud -(Empty. The open calls below move here once answered.) +- **Decision 1 (5 August): Option A, exact bytes.** Stored configuration text is + never normalized. The prose-side friendliness lives in matching (exact first, + then a normalized retry for prose fields only, per the match-tolerance decision + above), never in storage. This unblocks slices S1a and S1b. ## Arbitrations after gate 2 (team lead, 4 August) diff --git a/docs/design/agent-config-editing/open-issues.md b/docs/design/agent-config-editing/open-issues.md new file mode 100644 index 0000000000..b9653fb8d9 --- /dev/null +++ b/docs/design/agent-config-editing/open-issues.md @@ -0,0 +1,59 @@ +# Open issues and deferred work + +Each entry records what was deferred, why, and where it came from, so a future +reader can act on it cold. + +## Teach the agent WHEN to commit, not only HOW + +- Deferred by: Mahmoud, 5 August 2026, during the PR #5733 briefing review. +- Context: the whole current effort makes the commit tool cheap and safe to use + (the HOW). The build-an-agent skill and the tool instructions say nothing about + judgment: when a change deserves a commit at all, when to batch several edits + into one commit, when to ask the user first, when a test run should precede a + commit. Today's playbook says "verify after every commit", which is a workaround + for missing validation, not guidance on timing. +- The ask: after v1 ships, design the WHEN guidance into the build-an-agent skill: + commit granularity, batching, ask-first cases, and how the agent should reason + about draft state and pending user edits before committing. +- Not now because: v1's scope is the mechanics, and the usability spike's + instruction budget work shows guidance must be measured, not written from + intuition. The spike harness is reusable for testing WHEN-guidance wording. + +## Live tool updates for Pi and Claude (shelved machinery) + +- Deferred by: Mahmoud's uniform-reopen decision, 5 August 2026. +- What exists: the runner-spike verdicts (Claude handles list_changed, blocked + only by our shim's missing capability flag; Pi has live registerTool / + setActiveTools, blocked only by our env-var delivery), and the + untrusted-acknowledgement design in contracts/adapter-matrix.md §4.3. +- Insertion points, named so enabling later is cheap: the capability entry per + adapter (flip reopen-session to apply-live), the shim capability flag + (tool-mcp-stdio.ts advertise + notify), a runner-written specs file plus an + extension hook for Pi (replaces AGENTA_AGENT_TOOLS_PUBLIC_SPECS). + +## Codex upstream: live MCP tool updates + +- Status: source check ran 5 August (see spikes/runner-spike.md, "Codex upstream + check" section, drafted issue inside). Filing upstream requires Mahmoud's + explicit approval. Until then Codex stays reopen-session, which is also the + uniform v1 route for every harness. + +## Embedded (referenced) skills have no stable key + +- Deferred at the phase 1 review. An @ag.embed skill cannot be addressed by name; + editing it needs the legacy whole-list write. Needs a stable raw reference key + design. Low urgency while embeds are rare in agent configs. + +## Build kit injects a standing section into the instruction file + +- Deferred by: Mahmoud, 5 August 2026, during the briefing review. +- The ask: playground runs get a short, always-present block injected into the + agent's instruction file, telling the agent it can edit itself and pointing at + the read and commit tools. Same never-persisted property as the rest of the + build kit. +- Design point to settle when building: the injected block must be invisible to + commits and to text-edit anchoring, so the agent never commits the kit's words + into its stored instructions. Candidate: inject as a separate overlay file the + harness reads, not as text inside the stored instruction document. +- Recorded together with the WHEN-to-commit skill guidance in the RFC artifact, + section 9. diff --git a/docs/design/agent-config-editing/plan.md b/docs/design/agent-config-editing/plan.md index 2dfa008635..d12aa2101b 100644 --- a/docs/design/agent-config-editing/plan.md +++ b/docs/design/agent-config-editing/plan.md @@ -71,17 +71,25 @@ on the dev stack). A regression blocks the slice until fixed. ### Rollout and compatibility -- **Deployment order, dark-first:** the API ships support for the new forms disabled - ("dark"). The runner ships its `value_from` resolution and stripping, also dark. - Only then does the catalog start advertising the new schema. This order exists - because plain API-first is NOT backward-compatible for `value_from`: an old runner - would forward the unresolved source and the API must reject it. -- **Kill switch, two enforcement points:** one flag, read by the API and the runner. - Off means: the catalog advertises the legacy schema only; the API rejects ordered - deltas and any surviving `value_from` with a clear error; the runner refuses to - resolve `value_from` before any workspace read. Both enforcement points exist - because a stale harness or a replayed call can still emit the new form after the - catalog stops advertising it. +Corrected 5 August after Mahmoud's review: the earlier "dark-first deployment order" +assumed independently deployable components. In Agenta's real topology the API, the +agent service (which carries the catalog), and the runner ship together in one stack, +both in docker compose and in the cloud deploys. There is no sustained mixed-version +period to sequence around. + +What remains, because it is nearly free and covers the real cases: + +- **One feature flag** in the API environment (`api/oss/src/utils/env.py` pattern), + read at request time by both the API and the runner, which always deploy at the + same version. On: the catalog advertises the new schema. Off: the catalog + advertises the legacy schema, the API rejects ordered deltas and any surviving + file marker with a clear error, and the runner refuses to resolve file markers + before any workspace read. This is a kill switch for the whole feature per + deployment, not a staged rollout. +- **Transient skew** (a cloud rolling deploy replacing containers over a few + minutes, or a relay file written before the deploy and executed after) is safe + without ordering: the legacy delta form keeps working on every version, and both + flag read points ship in the same images. - **Legacy DTO compatibility:** `extra="forbid"` applies to the new operations form only. The legacy `set`/`remove` form keeps its current tolerance, so old playbooks and stored callers do not start failing. From c543f70c1ba938a4b559672cb620dffc333ad10f Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Wed, 5 Aug 2026 12:04:45 +0200 Subject: [PATCH 20/36] docs(design): v4 spike arm: executable flags found from schema alone; rename costs nothing --- .../spikes/model-usability-spike.md | 141 +++++++++++++++++- .../spikes/model-usability/harness.py | 51 +++++-- .../model-usability/instructions/v4a.md | 31 ++++ .../model-usability/instructions/v4b.md | 34 +++++ .../spikes/model-usability/results.tar.gz | Bin 46356 -> 51695 bytes .../spikes/model-usability/run.py | 3 + .../spikes/model-usability/selftest.py | 32 +++- .../spikes/model-usability/tasks.py | 61 +++++++- 8 files changed, 333 insertions(+), 20 deletions(-) create mode 100644 docs/design/agent-config-editing/spikes/model-usability/instructions/v4a.md create mode 100644 docs/design/agent-config-editing/spikes/model-usability/instructions/v4b.md diff --git a/docs/design/agent-config-editing/spikes/model-usability-spike.md b/docs/design/agent-config-editing/spikes/model-usability-spike.md index 15700bd526..682e7492e5 100644 --- a/docs/design/agent-config-editing/spikes/model-usability-spike.md +++ b/docs/design/agent-config-editing/spikes/model-usability-spike.md @@ -460,14 +460,16 @@ harness.py runner + commit wrapper + tool schema + the lenient arm run.py one arm: uv run run.py --model haiku --instructions v2 --n 5 --out ... analyze.py rates and failure modes table.py the markdown tables in section 2 -instructions/ v0.md, v1.md, v2.md, v3.md -results.tar.gz 550 trials as JSONL, plus the generated tables +instructions/ v0.md, v1.md, v2.md, v3.md, v4a.md, v4b.md +results.tar.gz 610 trials as JSONL, plus the generated tables ``` `run.py` needs `change_set.py` beside it: copy it from `api/oss/src/core/workflows/change_set.py` in worktree `agent-a2a2adaa5d154d454`, or from wherever the engine lands after slice 1. Add `--lenient` for the section 5 interface -arm, and `--lenient --v3-surface` for the follow-up arm. +arm, `--lenient --v3-surface` for the follow-up arm, and `--lenient --v4-surface` +for section G. Note the harness now carries the section G interface shape, so the v0-v2 +arms cannot be replayed against it unchanged. Keys are read from `~/.agenta-qa-secrets.env`. No key value is written to any output file. @@ -659,3 +661,136 @@ attribution in F.4 and F.5 comes from replaying the trials, not from separate ar individual contributions of assumptions 2, 3 and 4 are inferred rather than isolated. Assumption 1 is the exception: v2+L versus v3+fixes isolates it directly, because the document is the only other thing that changed. + +--- + +# G. Executable files, and the renamed import root + +Added 5 August 2026, at the team lead's request. The interface moved again after the +follow-up: the folder source and its three policy fields are gone, every file reference is +the inline `{"@ag.file": ""}` marker in a string position, the import root is +`.agenta-imports/`, and whether a file is executable is now an ordinary agent-authored +field — `files[].executable` plus the skill-level `allow_executable_files`, both defaulting +to false, nothing derived from mode bits. + +**Verdict: the schema suffices. The doc line is not needed.** Both models set both flags on +the first attempt with no mention of them in the instructions, 9 times out of 10, and the +tenth was unreadable rather than wrong. Adding the line changed nothing measurable. The +rename cost nothing. + +## G.1 The arm + +The harness was updated to that shape: `value_from` removed from the schema entirely (a +model that still sends one is refused and pointed at `@ag.file`), the import root renamed, +and the base configuration now carries `allow_executable_files: false` on every skill and +`executable: false` on every file — so the fields are visible in what the model reads. +The schema's `value` description documents the item shape, including both flags and their +defaults. + +That is the whole of what l1 gives the model: the field names appear in the schema +description and in the configuration it just read. Nothing in the instruction document +mentions them. + +New task **l**: *"Add the deploy-helper skill from `.agenta-imports/deploy-helper/`. It has +SKILL.md and scripts/run.sh. Its scripts/run.sh must be runnable as a program."* It passes +only when the committed skill carries the file with `executable: true` **and** +`allow_executable_files: true`, with both file contents pulled through `@ag.file`. The +self-test confirms the checker rejects each partial answer: neither flag, file flag only, +and skill flag only all fail; only both pass. + +Two documents, identical except for one line: + +- **v4a** (1,553 B): v3 with the renamed root in its example. Zero mention of executables. +- **v4b** (1,671 B): v4a plus `For a program file, set "executable": true on the file and + "allow_executable_files": true on the skill.` + +Tasks e and h were re-run in both arms to check the rename. + +## G.2 Results + +5 trials per cell, 60 trials total. + +| Task | Haiku v4a | Haiku v4b | DS v4a | DS v4b | +|---|---|---|---|---| +| e add a skill from workspace files | 5/5 | 5/5 | 4/5 | 3/5 | +| h wrong folder, then correct the path | 5/5 | 5/5 | 3/5 | 2/5 | +| l add a skill with an executable script | 5/5 | 5/5 | 3/5 | 3/5 | +| **all** | **15/15** | **15/15** | **10/15** | **8/15** | + +The DeepSeek column looks alarming and is not about this feature at all. **All 12 DeepSeek +failures across both arms are the `message` corruption of F.5.1** — 11 of them literally, +and the twelfth a malformed envelope. Not one is about the executable flags, the marker, or +the renamed root. Recovering the delta out of the corrupted payloads shows 3 of 5 v4a +failures and 5 of 7 v4b failures carried a delta that was already correct. + +## G.3 Did models find the flags without being told? + +This is the question the arm exists to answer. For every task-l trial I read the first +attempt's value, recovering it from the corrupted JSON where necessary: + +| Arm | both flags set | one only | neither | unreadable | +|---|---|---|---|---| +| Haiku v4a — **no doc line** | **5/5** | 0 | 0 | 0 | +| DeepSeek v4a — **no doc line** | **4/5** | 0 | 0 | 1 | +| Haiku v4b — one doc line | 5/5 | 0 | 0 | 0 | +| DeepSeek v4b — one doc line | 5/5 | 0 | 0 | 0 | + +Nine of ten first attempts with no doc line set both flags correctly. **Not one trial in +any arm set one flag without the other** — the failure mode the two-field design invites, +where the file is marked executable but the skill still forbids it, never occurred. The +tenth case is DeepSeek's corrupted payload, where the delta could not be parsed; its visible +tail contains `"executable": true`, so it is probably a tenth success, but I did not count +it as one. + +The one added line moved DeepSeek from 4/5 to 5/5 readable-and-correct, which at five trials +is one sample, and moved Haiku not at all. There is no effect here to measure. + +Why it works without the doc: `executable` and `allow_executable_files` are *ordinary, +well-named boolean fields on the object being authored*. The model reads a config where +every skill already shows both at `false`, gets a task that says "must be runnable as a +program", and flips them. This is the opposite of the selector problem in section 3.1, where +the model knew exactly what it wanted and could not express it. Here the shape is obvious +and the naming does the work. + +## G.4 The rename cost nothing + +Tasks e and h scored 5/5 for Haiku in both arms, unchanged from v3's `imports/`. Models +copied `.agenta-imports/` verbatim from the prompt; the leading dot and the hyphen caused no +trouble. Task h still recovers: the model is given a `scratch/` path, gets +`source_outside_import_root` with the list of real folders — now two of them, so it must +pick — and picks `pdf-tools` correctly. + +Removing `value_from` also cost nothing. No model in any v4 trial tried to use it, and no +model asked for a folder-level import. With the marker taught and the folder source absent +from the schema, the inline form is simply the only thing there — which is the outcome +section 3.2 argued for. + +## G.5 Verdict + +**Do not add the line.** The executable flags need no instruction support: + +1. Nine of ten first attempts set both flags correctly with zero documentation, and the + tenth was unreadable, not wrong. +2. The dangerous partial state — file marked executable, skill still forbidding it — did + not occur once in 20 trials. +3. The line costs 118 bytes, about 8% of a 1.5 KB budget that section F.6 argued is already + at its floor. Spending 8% for no measured gain is the wrong trade. + +What made this easy is worth copying deliberately, because it is the design lesson of the +whole spike: **name a field for what it is, put it in the data the model reads, and give it +a safe default.** The model then finds it. Instructions are needed where the interface is +surprising — the selector that stands in place of a list name, the marker that has no +analogue elsewhere — not where it is ordinary. + +Two caveats. Five trials per cell means a 5/5 and a 4/5 are not distinguishable; the claim +rests on 19 of 20 trials agreeing, not on any single cell. And task l asks for the +executable file explicitly ("must be runnable as a program"). It does not test the harder +case where a user says only "add this skill" and the folder happens to contain a script — +there, the right behavior is probably to leave both flags false and say so, and nothing here +measures whether models do that. + +**One item does need fixing, and it is not new.** DeepSeek lost 12 of 12 failures to the +free-text `message` field, in an arm where `message` was already optional. F.5.1 recommended +removing it from the model-facing schema and deriving it server-side. Three arms have now +reproduced the same failure. It is the largest remaining source of lost commits for the +weaker model, and it has nothing to do with any feature we have been testing. diff --git a/docs/design/agent-config-editing/spikes/model-usability/harness.py b/docs/design/agent-config-editing/spikes/model-usability/harness.py index 6aa192e89b..02ce69576c 100644 --- a/docs/design/agent-config-editing/spikes/model-usability/harness.py +++ b/docs/design/agent-config-editing/spikes/model-usability/harness.py @@ -26,6 +26,7 @@ MARKERS = (MARKER, MARKER_V3) V3_SURFACE = False # schema advertises {"list": ...} and @ag.file, and message is optional +V4_SURFACE = False # V3 plus: no value_from at all; the schema documents the item shape # -------------------------------------------------------------------------------------- @@ -187,6 +188,13 @@ def resolve_value_from(delta: Any) -> Any: out.append(operation) continue verb = operation.get("operation") + if V4_SURFACE: + raise RunnerRefusal( + "source_invalid", + "'value_from' no longer exists. Reference a workspace file inline with " + '{"@ag.file": ""} in the string position that needs its content.', + retryable=True, + ) source = operation["value_from"] if not isinstance(source, dict) or not source.get("path"): raise RunnerRefusal( @@ -546,9 +554,7 @@ def run_commit( ], }, "target": _TARGET, - "value": { - "description": "The new value. Only for set, merge, add_item, replace_item." - }, + "value": {"description": "VALUE_DESCRIPTION"}, "edits": dict(_EDITS, description="Only for edit_text."), "value_from": dict( _SOURCE, description="Only for set, add_item, replace_item." @@ -564,7 +570,7 @@ def _member(operation: str, *, target_tail: str, value: bool, edits: bool) -> di } required = ["operation", "target"] if value: - props["value"] = {"description": "The new value."} + props["value"] = {"description": "VALUE_DESCRIPTION"} props["value_from"] = _SOURCE required.append("value") if edits: @@ -629,14 +635,41 @@ def _member(operation: str, *, target_tail: str, value: bool, edits: bool) -> di } +def _strip_value_from(node: Any) -> None: + """Remove every `value_from` property from a schema tree, in place.""" + if isinstance(node, dict): + props = node.get("properties") + if isinstance(props, dict): + props.pop("value_from", None) + for child in node.values(): + _strip_value_from(child) + elif isinstance(node, list): + for child in node: + _strip_value_from(child) + + def tool_schema(*, union: bool = False) -> Dict[str, Any]: operation = _UNION_OPERATION if union else _FLAT_OPERATION - selector_key = "list" if V3_SURFACE else "field" - operation = json.loads( - json.dumps(operation).replace("SELECTOR_KEY", selector_key) - ) + selector_key = "list" if (V3_SURFACE or V4_SURFACE) else "field" + blob = json.dumps(operation).replace("SELECTOR_KEY", selector_key) + if V4_SURFACE: + # The folder source is gone; the schema documents the item shape instead. + operation = json.loads(blob) + _strip_value_from(operation) + operation = json.loads( + json.dumps(operation).replace( + "VALUE_DESCRIPTION", 'The new value. A skills entry is {name, description, body, allow_executable_files (boolean, default false), files: [{path, content, executable (boolean, default false)}]}. A tools entry is {type, name, ...}. An mcps entry is {name, transport, url}.' + ) + ) + else: + operation = json.loads( + blob.replace( + "VALUE_DESCRIPTION", + "The new value. Only for set, merge, add_item, replace_item.", + ) + ) required = ["base_revision_id", "delta"] - if not V3_SURFACE: + if not (V3_SURFACE or V4_SURFACE): required.append("message") return { "type": "object", diff --git a/docs/design/agent-config-editing/spikes/model-usability/instructions/v4a.md b/docs/design/agent-config-editing/spikes/model-usability/instructions/v4a.md new file mode 100644 index 0000000000..f644234c23 --- /dev/null +++ b/docs/design/agent-config-editing/spikes/model-usability/instructions/v4a.md @@ -0,0 +1,31 @@ +Commit a change to this agent's own configuration. + +Send `workflow_revision` with `base_revision_id` (the `revision_id` you read) and +`delta`. `delta` holds `operations`; they run in order, and if one fails nothing is +committed. + +TARGET: an array of segments from the configuration root. A string segment names an +object field. An object segment `{"list": L, "key": K}` names one entry of list L and +stands in place of L's name. Keyed lists: skills, mcps, tools (by name), files (by path). + + ["parameters","agent",{"list":"skills","key":"release-qa"}, + {"list":"files","key":"checklist.md"},"content"] + +OPERATIONS: +- `set` replace one field (needs `value`) +- `merge` deep-merge an object into one field (needs `value`) +- `remove` delete one field +- `edit_text` replace exact substrings in one string field (needs `edits`) +- `add_item` append to a list; target ends with the list name (needs `value`) +- `replace_item` replace one entry; target ends with a selector (needs `value`) +- `remove_item` delete one entry; target ends with a selector + +`edits` is a list of `{old_text, new_text}`. `old_text` must occur exactly once and match +character for character, line breaks included. Copy it from the configuration you read; never +retype it from memory. + +For a workspace file's content, write `{"@ag.file": ""}` where the string would go: + + {"operation":"add_item","target":["parameters","agent","skills"], + "value":{"name":"pdf-tools","description":"Make PDFs.", + "body":{"@ag.file":".agenta-imports/pdf-tools/SKILL.md"}}} diff --git a/docs/design/agent-config-editing/spikes/model-usability/instructions/v4b.md b/docs/design/agent-config-editing/spikes/model-usability/instructions/v4b.md new file mode 100644 index 0000000000..10d76c63d2 --- /dev/null +++ b/docs/design/agent-config-editing/spikes/model-usability/instructions/v4b.md @@ -0,0 +1,34 @@ +Commit a change to this agent's own configuration. + +Send `workflow_revision` with `base_revision_id` (the `revision_id` you read) and +`delta`. `delta` holds `operations`; they run in order, and if one fails nothing is +committed. + +TARGET: an array of segments from the configuration root. A string segment names an +object field. An object segment `{"list": L, "key": K}` names one entry of list L and +stands in place of L's name. Keyed lists: skills, mcps, tools (by name), files (by path). + + ["parameters","agent",{"list":"skills","key":"release-qa"}, + {"list":"files","key":"checklist.md"},"content"] + +OPERATIONS: +- `set` replace one field (needs `value`) +- `merge` deep-merge an object into one field (needs `value`) +- `remove` delete one field +- `edit_text` replace exact substrings in one string field (needs `edits`) +- `add_item` append to a list; target ends with the list name (needs `value`) +- `replace_item` replace one entry; target ends with a selector (needs `value`) +- `remove_item` delete one entry; target ends with a selector + +`edits` is a list of `{old_text, new_text}`. `old_text` must occur exactly once and match +character for character, line breaks included. Copy it from the configuration you read; never +retype it from memory. + +For a workspace file's content, write `{"@ag.file": ""}` where the string would go. +For a program file, set `"executable": true` on the file and `"allow_executable_files": true` on the skill. + +Example: + + {"operation":"add_item","target":["parameters","agent","skills"], + "value":{"name":"pdf-tools","description":"Make PDFs.", + "body":{"@ag.file":".agenta-imports/pdf-tools/SKILL.md"}}} diff --git a/docs/design/agent-config-editing/spikes/model-usability/results.tar.gz b/docs/design/agent-config-editing/spikes/model-usability/results.tar.gz index 9ebc7d3c9a60215e86d39822672a7a5b63b57f3e..9083f7822fbe7bc268073183d56753841a9cb893 100644 GIT binary patch literal 51695 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 z zJI3)CTRW3Ze8KD{9wAD&DIkSE;g#U*-*^+qPe&=7-_|gKHudC4$NKxFK$me4 zm%;3TNBHZr-1sier=*2oyz}kNS`$kj4Z6xUULVF*o3;oNGUGO{xD1_u3W^YZEhSgi zz{Jt}%J?;v6%ddG2k^fqJ)Wo|nYN7OnHiaxES|0P#~S)} zw}zxTWd*z9W7B57X1p*{*0-uS8z&Zs_QdE|zO6G=LpX-dgA-;JmwLM;p4-@5C>m#m z#s7{Y%1DM(+eYZ6zqy$@A+735m)~f4Qf=Dna@|m0E-vsrjJCaoz7?(9W}~ROdoox% z?T-Rrq9HvNe~BZ_SQ5BVP* zr*)HD;*(^|&gFA(%^_F(Tz-Qi!s(gxxGu~ov@+yOG~`V3&Vm&w+Tr^d0VgC}VaPY& z*$g?Gd}?F)c{JwE8f)=w;?puT!OJDVOB{_?g*X~sKjfu=7ZUy(JgpJFbfgQJk<`0h zci;3HlF%(7sZK*sKuTO6hQMYGhyF}_y6Jh4TUB1%znws9kn1cyfbBD(MyuBI!Z3oZ zVL4I4v-GivKHw2ke~{7sVi#%gUpzyur`B(tp?6l$#>2GJ*Cg~Rf}c&x>M+|d7c-Yy znQRr)D|jt121Wejyr=fM2yup$po2U*-G#l)k6C1dlCS+8$H+xLit8`*vjd=YGD=#} z(GEQg;{Bfiy)Q~18_eK>y*E`W2D2LJ7n(8@3Y0C@?oNmJq48x5<)Sk9WeLdDPjp+_S|bpG6~L3yoGnSQ52#Q$Q{l@Cj6us-3bg`Mj6r|=(SNLsLijj2T7Q2t z+t~g#+XUOKoi;qYoK^D04&&Nq#(k-_y|6pIoQl_qR2F)!O{f&ZWqG{&NC~n1!Y@06 zNcl9=5y?G@@LAqX1Si*`o|Rz4H5DmV9Y-s4`oGcrD27cyJ7m3%ZgT4?f2J7bK>(cVEg%Xl+KnBtNkVOSey zZ;O9fE6>{XVU5Lvo}r=sNaSbI{^z#jo zO$R;Q1duzC0Ys?2aVLpf7U+L*Cj_Q%+zE5railVoP_3=@TT){-rzOnXaZz)cYgQoa z1lu56+3(w4csN&v9r^3rqnoX@aMMLlk;m&Xz1`-~=j#N#df-adbg|3MmB4XJ9I_ej z;bQZ~um*Oq=2q*@^9$uCoL`Rgr{~_N9qu>F*b}1DS*wJF8x+>6;t$%2-GUME}jo^vyK1KJ)m1nbC%LZ46mP8=QW%% z?%q1;M~BNb(0XdqxHs@{vYVGEmVOmqSwW3MlL-GRGD~F9L~fynz4&h@jnDswlP0+8 zpH7;dh5x0KrV_KlYhIIMhJ1H2$}Zw5PweQ1xT{j~JJ&CS&VcjpTs(@`;sVQb*WxX8 zFo3tg46#V`yx&PIUP+|q*CXqc(BJPQw*Ku%km_W%sx0ua`H6?IZ%_5;pVm;*?RtUh z1-aSd>hbj;*1r}}U^x_z(sj`L?U_*Co)UK$?=W_yS*%pKl2HOiCcIE&F3;jHE>8-k zCd01gWU|*g#H63e5VIyieKJCwt``2|?`=^U;-n8_A-fsjCa$+}nlSr-5n~ppBg~r! z4;OJB{7^rlBDK*&(X@=p#LAetVT&DR2P}@r3{hF)qARkZReO%pNvB~x5J1^XYdHg4 zmJPsD}fe@&IUKH0pr1oG&zOUNa9C{twwFb^@Nguf-+5>AZKb7v^p?n0UdcZ z#Nsv#@>}?JAgQ_g9jy7(VL{$2iu>gOdWz*8H4nOnTL7;%zEB(a%?vOOv{Vil2ig_v zeOI@3%9k^L)=)rvWt&hr9ZR*8MgxVk{L9o*+m-X++*>8&o2$U~7YkOh90YJ= zywidC8~|ns#V#&g&!x%Qtb*)js_uM0*e;|U(m{cQw0;C$-@9ZNn1kRVEMYko{aGUhw>j|3)O_`gMQpkSBGS}+uJZ;(I=3?DN zB*zerejGyt#xE&C<2Fj;Ep^*~zea?6KZ*7%DGe)KoI`;J3HSbb>{+7WnH<|dMpkwV zlqKuO_1i9U$Ba9!N8K?VG~KK%NX@o1+~nq|qLcvbnu|xysJYVEDeO#S?}Xw7A=#bjNO0qj5QT1JOGs3KFqz_)ZGegl6h2BH#iy^oBrf+k2lULAS?h6E z=W*b*aSU29>QZA>CAQ|M zcECXR`|0b`F~nSIMtt*!(J2sgReX~F0dVlp(#ELN&5&TCVai1Y@*;#nzv&TIiQuzx zbKeu=W(bJAzTGVFC$-spQWE{1=3B&~m6i-7vJP@oL;20vU% zE|fB0)-?`KJDSBAd6dKkuOZ@RG)|c_R`aF98MNkpXTxmJ>N)~d3Fw}BSZOJpt-lgt zM9QmOy=vK?jproj$Y*%F0S_5xa5%GOVWw^$TGmq*3nS?;lq1(g>LGK@Ymd>%RG2!c#QztF-l(MYt@iPxI3!3kNUq)i^PGo2$z%Qeb4U4rI zU#|P?jUmTvrQHZeM}_n9^@S)~;1cHZpCwFenVIAexX zPd_KzAA9-ttxYi$0-J*;5P4F3Ee#cNVP_QRV~Hs13UsV4Wn3v`=Y>D@D_{JAhNQff zj}u3j&Ye*lOrELus zB}p4gUZj`bk{5seO{!Cmn{#}`;6wE(vAY=Gm~(bZ9_32jJX(CZo5ZHZmc5~Wo{8k` z**J85g8u54!57h%xnwT|AG?6NhBl&NH%7(5c~sMy*F{RGu!h+kDJ+mjpb$eLjzR+S z5j@oYui+FOrvvq=;FSp+X2(=(g58A3!0i0^g7!4c8=GEQK-sid4?;p?MJAB8cMA*> zG9<<{lxq;|$61MpEqtCO%DyiGk1t##mZ28!Jw9C2ht0Y#zbGv98T08{!%W?7K!+QP zNbuu1cXywihp%ht$7V6BYH??J7FLV66~FyxpKW|jb}fm$AzzE*s8qL_x)HKK{V+Cd zYExqoH#e<(og%3gThjB(*@?9Cpgo?2MbVfzoQU$lv@ou)5hGp#XvsPJY8WP4iblAi93`lJ|?M=+lbE59gzvom|HjNlA>;e74!FC*e&Lg zU|V1?AmYn0=WciW_h~FT^GGxB?a*yY5|%1?k;oiTdE(*^^H7#xEdE#mk?h5ycsN)@ zY0fM}MV{MjUxbQd2Bhrdyq9=OgQ)w3N9xf5U!Uz9vVCZ_nt-Aetby!2Rv=^Odvd-4 zzPgvk>q$)dzG^i|bwAw$O^q2wOI3NNm=ALUoc7W!zY)K_{LJz%eJ2Udq$tWUmP*iW zRWU)XQc%!}xFw8g>vzW+ExDAH+a($4c8c-iPXFf9tna37x^AzRZBie?A7Tzvs?2#> zZLYZ-G-eDQjw+06UbQK{wS_C?O*Ge zrEZBYqoCiTDoq?zk*wUR9apz^K;5AlR*5MiP!p*1*9LEa`R0gm;UP5wmPi$`)1fHub|ExtDB;?06r_*@TCG3`Y-Mb#a^pvyPfU-BY zh*wMfL#$WDN}zU&=TC-}}SGBpLuV?$OqxR1k$#%bBurBX92%shc^PV@^a(6ZP384uk< z`@lYTDCplC$!ca2*;+~g3+{~_oKrvNzh$ZIYGlH%k6LoWYS=pJI=Yz7WvpsnS$ifw zTojStXU893CX7;!7Hfu|gJ%4=apOF@ttOf6E~~{2zu$T`y{|d*Z!#)464sck-qGx-sk1CN$k%wEvD{qOtDVsW#9i@|=2y5H zYKQUjvEKoqahym`-~sk*Tr(3jn-(6$4yfOR+%X>~+GD>p$7S1GEfN`EJ=l}1ej=$` zCOOVd#nn@!)!Cg+wyqZv2;9Ure$dv&pih>n;v+{xU(2#68;YOMq2@ck)jTekZ&txSPTAhGsrtz*@4eE0sj+GQgnzd^ zVSi?6ld*4>cH$nHakH1%?pA^0X$Y{lbiCYQK_9pGG{^u7g6!ohYcG1(^T2Appa?uSY1qulYNKVQZ7Q@G=m7*-8{UlRBJ>q{85CGZTnaWJUJ$u+uM$}sVSPTDYc6EP&v4_#g z@cN!i&WW5%cBipsC058&Fq#b)Lx6o1EPz>SUTVdz2s05T z?iZRKct~9|l6~dxmSSHwjMuBdkUO`cc5SEzHNwzde9$a7s2stfP#zx_Nd(a7I)k zvOVLhQ{Hm^ykIb);r?vbdLo=8Ix@_O7cem0uzIo3()kTNC^wzj|KFwOrkY=2og#2ZrznNUKm z{=V10>{`z9g_+JzahBAAiozLZ_@p~h&I!O0R~TtI@XI{5{j2D~r=CQd;Y1Q-sxe1O ztE^;PImMEB#{-EhVB*Aidu;;5z7rK|tHSC@H2K|;Q(V2W>@l&#*@I+U0tuyrV%%2} z3CBzl9lY$3usBpw>Z8x^I9fA_*qn68xa^sG4QVCZPy#rQOb>alVLZS){B;GYa1wP> z5g&?C=V2^OvMqNfDt)crVe4?aoyx`64Q;hROij8}<*La3M{3?tqUG*isd<()3ckS9 zJjsSpU~1l(&DU3HX21WsdY6iGyXEk!J08z)OA4wfAAPk(yxgY1k-AfCV0;#2Jwiap zGy=EAuOH#5aX9!{2dudanDbHC57x9S5+jaD^SLWNtHEln2 z`qDnAvf^N~OSSs*Q~|%MGBNivpy0DB(WhvUu~s4#qdUK9j^=8{lUbzb4Xy1oj z7cIhmXnVm7B=Y_it}Q3C#>2w?L(Aw}SZD`4nB?H4{m~Jc*2E=|otT5cUzWr04gqTf zP1Z51j?@S(QnouVhdPC>9OwMdf4?4H42Ioy|G}hmk}sUssq<`WbiG`*d)KhVA?Hj_ zv2WF$_V(E?#t71fiOr^fD0kI4u$?x!o^LJFKT$NcVVc**cc=0<)6BlyIn}+h^;{QQ zQ?-*?^(tG%bG}L9{q0&v^qCK5F8!+kCP?c~1B`G}D1StelQCl=cUN+wPx}FF)1Ug6 zeWCV8Q0tRS#=0<-gZemth3whslujFS`aG?*sFD!?Yms)ONit27k`p>JP*#wF*)smY1iq0+`5p`IZI7M zE=2^HT*v3=?qNf9?416hj8>mp1JZqFAEReQ<2&~Cy@$QQ2$uyWJ zKpc)o-w~i8Xsp2}0gZb<^4XZ5shkiq%pxu~Zpr@Fr??+Ym% z$kV4sFF&h`My(54{n9tA_3&1cldjv#I2VAb=l=T+dhE#hn!|#zQA#5wjQH4nM$8Cp zGhql~rg4F($YHnN%V{T(~C^B(9Esuev*m3|U!8vor<{qFn6aP!i3Dp4y;GG;@1!UW zk@H7yBm_I#U;;#QI7D+ja7TQ1Bt!@#cQK@sEs8s%^LfvaiPw1#XcUp$bOe`Fl>XQf z6Eo-pKftYfjjdaHMw&R%v=S#3Iaz(HFH>Q)949?}08-nLuYC7M>$5)4tc!Nao+rY! z#yZRbqB+`2WIv|GKqoj$C>ZDHq|L~6I5}tEaWed~H&i>DEB{QPb-CKg!F%VScH`a1 z&5!rEH=ieGq)XIgb5kw*Vzb5wWC3T_+D8g!ZvtgFG&qyk*qx?XFs@me)12NLTF<)J zGeVgndOr3Jde8fQj8WH(qHee(R#2XWbG{ejb0^NQT4%nK)gRwykOXz2^Zi0Byv3PE z8`c*B?nKe&taSdkpKnssY}_cf4S}^B^|Cyw%#3um1K*?)?wGrxHf0CT>4<EmlV74aP2~=AJsKe>wHTXVa-HS8Vf@%oft&oKF z8Oh;r&XVP8+PkL+oGfpF!ZSCn@E#w&`&?xe#rg{kK_({Y5`}e54IanM`?M`ZQW~EX zLoc&CQ8|xGiBm~26muN=OyS&GGRNq^4V2S1A=l^tgEKhlFL31_I+HFnz$kSzGG_{(gcAbK6fm2{bee?ZG-C?{ znYj-<=x0!NRU985B8SL-8|d^4u`o#b2cl=h}~M%21S6Q%a7OS!lBiT$bcXs@9< zwfPsNX~t!*>;FJkU2lPq6=#U%!EUO!9ityhR{B>EV>LNy(FvcAG^ zmvSe*%P=!(dyDj2{1fRH?}S+u4p6!4B`Wat(05F#c89UQjp(U9|Dr1rl6!hwKdsmv zuUKHe*@O>DYR(wS!$E=&Dzs(&-kXH~p?;IDnMu3(J^C3*E~XFClvyzTL=j*t{BYc( zA+7O;=`Pg<$22mZu;9m94@?c<5}i4?rk`q#O5?O;R|~Hz+0c{0-8-ovw(a*y?`7L+ z5f`7%ShW>t0sLZl(ZcxbX_PpmGmw#j`P1f$7T4XOyg3Y78l9SwNmZH=cEWN=z&?|) zfAyL8)OpcOY=9pO|BT%Q6}$SJZ+xGlV2*+{!5JC1>xc>iNZ1XRlOY=e`lxq*`>2mX zS}eOXcwJQ+G?@5|oM<7giRQ9?hNdN5L#y2WgJ{ACgEmAhp@pLt%T5_jo$ z{vOzIG4i)}xlv!9Jwx9EOFDAYnv#c5Y7aJS>xZ9%i_t|>RF12rvc~&KjPj}3<+pUN z-N;s=c_)}GZ9=JAC3gaD8Ezy7l6;ZU;ns7|7nBaJWk}G%zrBA@c)_4!*n3dR_N2wu zOA&d!+c-dI1BX$KN;KMiZ2I|p28h?8j#jyGF&>RcK=27MXFR9@nU_Lbwnf{tT`lzXv#sO*~&Nui55aWo)UJq4ttR)vt)sEPH6` zJ&{3uSwDlLrdRV_#tVY*w5%mpvf};xT*yie$<}B)k8R;Tq{y-ehR|H&ESS|?H!X*U zgZGnUGm0r%J%GDswB~2K7ROc)37AWU2$zf8hPLYLj!eZtaJxkLr|+`ouu4nnKR%-( zV1OhxLAyni8Y(C91}dnGca8llCQxPngpa84<6So=XSZPx=x@6xlbkx&)DtNLYjd5P zyNB7ZVDuOE7l|RgMF~T(P2&#gKm<8X$tZL6fJfHJ_Wg({PuaPz1)3}E`5$ei(_TBy%lv$OO_?oH3V}fuZDg-(SO-07 zcZN6E1wVK;?Lvu8ZRf$9J%3BM$74YC) zb9<0~h@rqI4YDljNYOq1%ezviBzK5eZt#g7UfIm~BW5|e9=*awyo)KvyYA<(jJI?N1K&-^%hPzNEeY{=1hnj-qMFI_vhDYtS z@!`m97rT`2SHc8gPhR9x=qML6@a%mK&VY9vVtsrL*0MCw;H&`?`hX=kRx{M}Iy82_ zUE5-j2|0#P$HVKcO1dhB;B{P6Oi^Y5JTK6ADaqSW(( z)Vr8<-znC2W?nzZ>SKB&2!w3pU*fZIvTcpseV)t#9Kdk~Os0zLmxIfGzK8m}oQWFd z!10N=&4HtC$t!gU)XA4`X21S1t(`6^ZrRp-xQ#gj|dvdj&4+I zX0KL_Ch(@u7#?AsOW`8H){1Nyo1rfoR%_@m{!FFGTAB1Wm{=aD5i$t5(N&tG&T z`$U8YOL1CC%EPE8E50b$CMfQAm|8s_j49Lx!qASf3Vg(6E=q3de4X5a|ub0IrxfaCh%mh)Bh;THUc5*)F~%wqo%27lpb zf)=L$Q)Em>*FhmOp)q>q{{5h3CtU6wFH!H57z{?`C#%SwkDw)_EHu@#Dj3HJc12l6j zZy9xmy^=q+VRPl7u4zD%VDrZK@ZsbLMM5Ng21drGvT7ti0K?-s*UfR}F&Ye6WQ>$` zaC*)c9oCOMyL!b*SHS3y(B0bU7`O!c+}j4gqF2EH1@CojWw-W^Hg%S%}un*ijO1OzHu4Y_Q?`1mbj< z3^x+*v$MBIjwi7IuNc2Ph?%ZVR+a;~E^Z*-mfDn;gTm&w=p-b&JlhaA`q5&G%Wu{b z)b~9iYIIYIv(Q(cI#PnT`5;$tlUq;m-(q|2&>=$JXeW_GIwm+v13|m zS$4d3MXoo<+je*TyVtz3U+1rkCWjKybhfirh6Ma@(uekH?uAyLd>X)6yB4`*Je}2V zZ~JGi8=7+?8~5R6v7n9Q%?@j~hS{?MB^}Ik?bkdfGYWi`#rE(Gh*Bt$IS+~} zu#WsZ8YOrtp)&JXEd^O}J<4)dIB1nR+NEchBB{|mz1A?>>XQPPlV?V<@pM1ZhbE85 z=5UD4EuJ#m9`Q=jdf#>$PKnt}jbR5pg6x?`q|AergnGFSqbPA1htatVH`lo-mBxEz zpOHA8;?K}u$adb0F>12RgWl}43hiM;rpe0`Pr81Qu1MibeHtB*pBT`EsqPD(>#zWhu-nJ7w6Ia3`3oCt-s?@dvbg8r-EBCN0*Dez`%ehF)sXnrlpGwG$nPZK(RFLw`1$DDzM>vL7=wlA=~{ zOp31BaXWCb8m1?5wh9LyrwA-Go@}=9Bh`Ku*DT+h^_$jp8i>vQGHco^RA>FXqO6PE z-bZg*!}Uaf*V-4oN9^j(=E!s#^9~?bU)> z{Z)#M>)8`)A-REdowb9$rU&b-aoOCcE{7g>!IAd&@?6n zEjvPcB^FP85G&C%kJ% ze17VAsu1i{;aY6dmDf{GOp?vUG4AHbf1OBBL;_;(du;v8Wy=F`dudBS>|3FBeMr^O zm5_cH!(HAA$bNj*-9ZD_%J!&xPYxdn50%|x8CZgg8PTqfZ8UyDy|yzwy_91o`>`(4 z`ra3xDVh(rT{|@*{To{Exjm+LLts%W+vCj?DP`1Q12BJJ)uk+-qgcqHFVcnM*|K{o z?b+|C(x-Qbdwbq~y`@O-G)P>AjNHiMt#qpN`4)}Y)qOgByv{THZC7JrFfgVf=YJ?$ z?stk)cuAt%Ee5tMS6#vG#pO$w(M)pvvuD}q2hj$_c!TmJ$0$Hf?5l5?jhQO$K@-!YM%4q+TL< zV`For&XYT0OQ-J6iC9}$@Jhf2{2t9!e*Miz1uBoO-PiS?8P)B}?vP>w@&`vJZ+pW1 z>{Y;I0KKm3G&G%P{sn8^#|FSxlflvBNsUt_M}FP9 zl{re~x3UM<5(Vl(U-a+>OQ6ZElX77+(Xv9J67|?FtN_?out9UY3I0}4>{Uv-4!*Ee`ekefNs?5w zrC%M0oqeoZ(6aH{Kod!(mSG1EzVa~lP6W;xvcCJx>Ts0BZ+#J+LpJ(S_%$}t6&DiU zC{+HrqvvzmLB1dJ%_7ZZh7u#Nv0_TgAJ|wCgs?G=W`_qWuX_A$_s8dthJhG_q9Gp) zoq{mdm)F4!$I%$$a!G{TKjn}JLEH7FUy!w@e%Np_v(a!v-Uwv-A@er?$lKkIO??0v z4XpSIl|lg3+{a^&J~_kmyFQ--NTaK3SJBLxL*KgRxw(B6>|-szy!4h|?(*7KCl^_^ zOg;9I_4j(al9*9kgP8haf%Zz&;5+X)_z_|}Wwxd>MMT7L;@F+A*d=iA@PL2f*nht? zfU7-K9H$lF>?yG&ua$gz=r85i(kJL<-V;+4rTOfs!hl!655veu*{|tU;SmyrV)*ho z3WZ1Y@fO}v!E&7n0%@o5>SkyZT@7Y+Y3Gzh4b0lU;9nExhKii+{gfCS8@r! zhq_!fDrvMS$#9U~5{6|UVl&y2cyq$%nXliD4`Ng`+~cnA+)%!6C}i3CIxEo5$6Fse zO_6O_FnutNzv&_#YOR+oa(+pU6kl1pd4`P5CLCebW4ks%@Hp|KRhr=OeW9}lufDVT z^Y`*j-&z;5#yh64_xU{s?X2D0430;~mv$TufFGB7xBTvE zY@GDp0ZfyafVMi*M7Ago-Z*R3WLf?0@R@}sz)-`Yn(r0Uz6MJmVammzt1!}knyz2(n z{!mNIwo-4&;H{NOh0NZXZDm>$2f$o#k?S3@gRks;ZYCs|KQsr(W!YfE+562btirXE zs3^TuZBc#ze!=kuOnt9YyIej*`$sNP(9QqOS=m_w*xLQSI^WseXE<`mF#2tsNh?VU z*i$?%QMMtN2rbLACcyuJOqo#^vr(eIhkxybH`x!WT~FOFOpyh))sZ6^wF%e{x&&j5^j3sEKXBM&;F>bxJ8q(rdI&~S$Dd#0i zW}DAffs%rpwEirdXTKMI-|Aan1IyW*Pcj*`P&#Ch%pIC)ncDF}!j5gCJ=(=ek{j&+ z-8 z79S)ib@ZpoZ15-5aFbdueN+wJzpUubcG-KA!UT;tye3u5=y#Hc!e_8c_I7oU_&rPh zgFMBx*8_N07q}6#yp3hT&Il+2VW^9gl1f-N6ucm@A2a25+6+WHDg1K-3S>9)3S>L? z{4AoW-Imo@4<{JbOzvb39z#7<;8JkV)hgJb>HjfHm5A3lV36U6FG4AWvZEAP4 z?VZ2)CUchgD#63BSw^5G-~P>#fBhB7T{$=W>{QfIh*fBKX*`BuBSUsScsy&gpV%n7 ztcA(L68R9A<>%hg*pKqO1kCbFc?h&HZ!xuZbarQ`j99tUd^FHWRP3&`MpFc*aRf;s zIr+>_a6y2C(G{(Zqt%|gd6KNCj-s)5w%v5N9J?Re#XC0FMKT;(0*+rG0p9nCtb*fB zNnV`*RFdc90+r;aL5$VQj{+NVOG)ieqP?(~GSmx&1!q9FKUiM$$Gr7a{RD)WF$4G5 zs`m%*GCrQ|9fv`6;rq!Eg5XmVoSUD^mZSHNci2* zaC&cBJn;Q3Fb`Pl2J^lp8p-KTG zC614KDX~Zlw4*)05=uo~*B%@ilZs}(l~<{H7CbxL3jF}EP%11LE=Tt#CXZb5k(Kjr zv3L~Swb~4}#wEfHH3}nHMY&1kq9oegl!bepqufhk;V~lhSH^{!xT zGErKQ?~7q9V00kpe&O9B-)&{Scd~Z>+tOqHzq9lhT_px`Pni6lLRI?#qa3fJ%b6_F zJ@yHJQOw;8R3#A(zOoD%@(X_ly8H}e zY}T0T`e5Gm*S%96gDcXdO#R!HulJWLKd8-gz)ZLvFElYnDfwt#l1HRSwQ9QLK-r0k z6E!nTR-`CT36QUJzRkIi+0gsKcwxhSu*~A#H`I}Y_L#>r$;N-}ePZ!)6gjTf<$3Qg zLRr56B>d4i?p!v2NvS>nr4q_Z4On4Nm!9dP|5w^uN5#=?58Aj}AV_e6y9Iam;O_1O zcL^SX1h?Ss?h@RBd+^}yZea?N-22}9eY0l$X8vJ!ovN!-8y1o zW-8}uU^(w9Z#@l=PimB^ujQXDKK)20^TDxt7W@SB?lSO`q^&;}&m=wzBmT+whkJ{I)Pfx;w&C8QYU0e=?t3Y zYx?tzYdkIYg4+lqBWrJ})2lZ{-oquRCUFeKB*b~&%~BIas01!H#V?}x#4?D+klv1A zm3Eoo#h&BGIdGMwEUAB4{nAcDXrnEUwcM=`M$8e)RZv^Nbr7VU-l9s2fv@vOnfe{` z*mi~P!q%dGDdx`XzEpjQ9G<3oMM%Jjyb~JG(M|$7S`TZ9DY}JnYR4S7Ix={$^mtO~ zaLe1`wri33-o54w`Oz92aC#wuDlrd6mK|wPDpcKVcr@WG3k}1}rzJoK@p;8sbD?Ir zUQGZ&!?|Hq~nwuDdzXH(@S)g>0r?fm$KvZBE}XY;tT?(5oiXom@>sS>g(6L_aR^koS2pZ=?mabS z%ZKHK%gy3*63>)qq+WTkrsoyVI=T{G*{qL%jl=1G3u6x~MO?>MJ`y;R6c}&QoY%A5HfgI+-I7TkMU^*-M0FG<5 zcGzXurmzXIC2GF37)GEe$;P9i^h!C_RCC@DpA6ES_Aw0rAK2?k6Le+AIovw&KRh#R z5J9ka;7zLT)v)Re>81_0eAK_&h?xcH$<*l42j$b&o(KM$HmNtdPU5}Cg_VS4SIYZ8Na5DTWX441 zmFte13E_4P^c6UF^(f#RzAD9L{JUp zPBAj{NU;?#MTI$=R$1Y1un(9;kCSA($faUU-mUVab?!m>@+H5C=DBYJR0_(~H_7`+ z#?jsMrR^<vX2x zZoVPo7d>N$-ew?n>;C}6F}$F#jkzW9w3K|KI!~L$P$g<2HLgy!BM4r8Xm-o$p#!d0{eU-p`%S7_bz?&W z`{1){f;0cXzQW0TyyK|Y7dQ5z@`eS>WEpX(<58r{V)tJ1*j0D9;xdH^Ybduvk5VRa zTrY_LvA4ba0qMa&dd6a4d&GUS~F$PxMp@|Gv-pxrJ$L+h%4;A z!D?QFNL5A?vp~g?a0A62h9dx15Q!*3n`nC^i@?(4%p_ub;yG=EjH@-{KlyI!gB&So|~=Y~j1{*fD+7FB*~R-#rdsuiu( zpl+p%x5(9452v!ZW}N_736lTaZKa+z61u&+n4|=hOj2pPp{$`&HED6BQ#94UPd2@( z?mt@27MSB7>i#P+R6#)t7kV~rOe~@nYsqy~VO(9?$|C5jDWpM2Yfnx4L+ud#F1IDg zd%SQKkTKNNk13`Q%DJf62l5li!Mq0hSNP)358Q(w{yp%rn+FNZ$+QFFp9cct@9H7k z_f6oxf1GV$XX-*dz`;z_|<4?hhMn>_Yb;Yt3C?u zA4qlnhk9H<(EM_Ew0MyDxP0y8X&Fw!x_7g5xc392j;cEO9%^Ij_E)c_e7CBlt~JQH zi`&@-H{bN631h5tt2hS$^}|a{2b^a-0HrWgN^$tQ8Yd=56^7g6R}B z_h>@vBJGJo-zNJ|sl_k|$rWBggX!sNWZbBih;9<7o@%TD<2V_X&gCFC za1(Vp$i^FW>YtWDzB8cg{&u|{ZTW1yWpkuujIaBg4{H8bPS(q!`)piZQ5yr=@4%w_ z8R*gsee`QakLa-2Vl(D8?HSxyF_OoAGG#3F?H z^e3o;8!RSi!gPZ$@*|*t{|?ol-3`p1!AB(jJ`B`=^&?h-NeAdZ4g>El@k=B`Vmv}P zm_{pvdd6Zi;gnN-n;R+|%O%f$FGBC)jz(#6K7!_wy`EiZ{er@tTw4!12T<6V=@z?} zlb7a)Wj1fKL(RuAk9{6D4>b>`(t~QZPaOMg9F|1RPV_92+YyRf$;CM)EHEDSHbJtg%Ln50E!F5@- zyURqOl9h&A8romQ+)f`n4P7Xrnz4%hxd`)rtTT&-kmQYqb%H%|Hl^WQM0BCIt)F`X za}?CPYSV!8J8&T{9*$guygsU9+;g5d=UQXR4>FKOCF64@E1%Z0PsAk)9?<_C96C3S z5OTEB2Rcr(`|Pf4LHauEY;kd+p942N`_ah81ERu)_mj;;Tz)koI6I#+JGtr~Y067* z3tD~PMcO_ZnhmSdp`+`sbcU-&E8%x_tUoW8DvJqJ_;Dg+Bq9GjV2 zn9ZMbEFdygx+QLAta^3G%}AHqwQ9c|yMSx+xL0RAn+gO=v2u)S_#FUKf!$Sd)`&^0 zNx?IIkX_h1;~75)3d#I}AH)LigDw9*rh)(q)|8Ham-n8Su8I(P!R~QBhr1bEZ1XElBDO21Dc9P^v<_V+@+?a~5 zmpSg}AoUE$onXzppt^S6S1u3lodQ;?eRj+ErQFcgo&Bgpb2diRmK< zDZd*^M%aoNjU?Uj;D;+IN*Xv}hwIaYi-Z32e%pCm3GI7`P$%aYlXrtAN3r zOk_yr;mj?Id)OqPc5pP4wYAI7E4#!Q{hsp|lN=w5Jj*aDKu>wRY|ttH_?v(H2ZMZ! zJA1s=a5SQk1JGOY{Aa}b1t(?eiw6j;lqK1Q5kD`j91|95y`|M)E}8-QT1=T6FML&M zy1B?s;OciX;XQb_lNu%mV`yvtN$}z*|2lC^bJ-1*;KCZ)w8Z=Al-yXF=1Bir>H`Y` zP3gV9?)icQkgOit5&VAE`@Do-N=tauW{i;3Xjrw6#>m<+Ntn^Zwa1-0b}n%mm7Wao zr|jwm;IS+Ql!6831q4Vft1MpRJ@pM(POD6Ho7h|KEzgW;niqdoqDr82 zFIFh)!>($1$2=vGl;CWpdhp)HsE|6X4tDeUm`vM^>O+?jOazu#i>6pu(!sE_vlae+ za-hGZhB8%Q@v&}Y9{L<_m6y?d|CKfU!u33ei@me+%)#zNndxfFTxgtQX?#2}tx)=7 z>MaF#^_-@iYjr+Pces|>!>(u>zQd)U%qV`~Rb}`E$D>>;FcT z$%bp*Z75CLRL!6U7rPzN9F^a%NCPU>Uq%{R31QF3)!(Cp;lQY>YO;LjNtY*I79C22 zDMS~fh0us)&$waG-?Kr2xjy0?jj$`2VWO0Q&#mlW73Al=WJRJ`+OGz7L4Ak*_}N>e zElYR2{LM^x2n*QaczMaIc5KjcNUs@~j~7H<;sg^ep%?1JA6dXa&W!n&BfuNEEFm6V zVNKNWl7vaE6^B8yhkC@Iw}W@lw6Le&z|nJ5j2qeY+H#*P}J!3A8drokYP55YkGj6DZ#CKN~0 z5;8(pGfa>_sJG{!_eJ*kxAE>TrNl4qxB;hO2Vtnc_D; zeb64@Q-+)tFCm%9-71)b4dliT^XkFwyX}7Qp6DLIzfk+m0Fm zEWM~{BX(IoW~BhwFG;YLe^GJI#5kMWg&(PSf$?#wBK#>U` zK{^aUJB01W@K8uN#O)(Do{9eW)DY3QBpfhk_-U|9Nm>Zc(78YCx#xiY85lKv9>rl^ zHHHKjb^3ydD9@p@^uXa*CGDiY{@5hlG}6{s`i#G|i|>8qLglP1PM@ud81^BYnk#*wm>Q$4 z`zZq##yzPI24~&Nw*zbMLS;-HQiO?!@hE(xr2oRp^nb1Wa(3Rt4Dw z^41xOw4--}_;qQD_6KyEKNOQ0B(8?Y=LYh(+0x$^c__|pk6=qaRY2QZ-{DdWp@D}Qo|ECV_5CNCo&hn5zY@Z#}k6plr7}Eg*Olip_$g3;2}mYCg>BpGosl5T^Dd!(kG>;JTARB)yxvrqH>%yy}s=;AHn6i$K3?j>FPgS6v={GQCL$Icb`uZi`v zd){om>enB)O~fL*uOoCSyTE*HMp89PA*3NR)!ry(jlnB-@K(&R;3o%PB`mIVQjfvW zg~T~uMsO4@p1fb0K-w*KWL2mUYE)S%b=Hd;d8`+m*%uZiB(|p}R!ctT*XR+xY@-oS zaLJ?g&v_%H;NXw?_Mx2*VY7DhzkyxXN`Sw-K&|3|geJEzB zuO_t!X6G3-Zjp?rFU~oz43M2X5Mavd;?Zy?s==4+eAX#jgyOA^B8}tyd9g!)Ewg5; z-cN+>{PaAhVQX%cXUPa`13ST-P<8H9T$O)hxUx*F8|Z5Y{EaSNu%H@GZH*NtHKB=X zU{^Xcn+WDwHiX<;H17guk$3}^{E;l9QEbf7Gzy8I*)T&Q{Sww|COGK5ed?dLGY2{* zoLwti#O94#LijL!Onmbwt(2%$b7x|CQQ@z6WN!7Y}F^7 z;a9H$5(qkrr(ZPS{wFU%Gx+3aEns_OBl_5@`b@);BvU^4*hV$Du0lSkc8LvyZSLfwNU>m` zCX5@u;wE#&XbxmX34y1#liQmCuON&>o6xX1f(!EZq%Y?#ZTT5b&`0Y6wedt0@x(Z` zMShvH{CLv*W8=gw8=WaA@6#m*^$eal_V8{EGm~fAo1M$zTzjQdWvf*RcDM+??4Jdx z3Q^z)>mwD#k@}nrLl{_j;~(|)u&|(leD(Db9~h+#b{e>{!_w66iYge6s!((viWs&R z;czmL4F^(*sxB%wl$|#M`p;yH!bpP$^q8O%X%bqkk4h9}^FA-LHo(xmRfI$r5J}2> z3ysyADveGY^!Mb?uc4l-|Pzmgs z+WLqQ9gmCmv+ob1`ZO#0t4usHkGp-H=Y`F)?~mn}`;?WF8+%garO6qQ*M{w-KcO(5 zjOnF6q5ru*;g7eRt2W=Uj}74n(yWF#(Z$meJ)M1N=)mP`4^@u>M_|u)76s`FEE=fL z4uf*T|JUg=W-0GSLYy~3UK{qn+4L9DkAGO=Uu5yy7|lyb(oud(timDz7DG%n zG&4;4=Z2=|P*zA^yS6Ggeh0VrKNeFZ1arDOjvxWZ za{$%c@f=3boXM|foGnSB}M@Vj{{n)Wc@~ z%$U)p##+8=pDwX6#sTwWS_FpYd9vE_L75B)6I3JE9ZA24tg+zz&LoY?Ogv#0ffTP9 zGHDHB@1rkwleapU^wNSJMbN!Jmfp^JOM$x}@`ui{#QKQdFK2zWpcJ!hcUsdno~z*c z7DgbJhgtT@(yLSUhp6v|k@KUg{3-Rn`!g6n@EXY5pXzv5>q_d&6DBu*S;;dMOB4Hb zMol$BA=|J7x6g)7N>^Xj119>BF7wwMRh^8KVU%r3@ssk)%S09=}(eL|7_jJ z-es#lpZON`YUa=#e|FW*6&F9a@85yjfmdw``;;3OMvzGFv`VT)#>M9OI#|&>T-o^ zR-)E?;%1%7?#ikGR3&oxs18bM1&9K9db#kts%@76*fO$>1j{Z8 zh3}oDwLpkHtF3%tR!jpwtW$~H%t)4~1)hl9y!Cwb4MsPBzLTxFldbZqjjM}%ZYf0M zX+2}ebP|tf`DRZ%Hm0??x7@x3$j@4kl>GX()X0^sKg+4 zV!?(-e~QuC&y-ut2JNhIH`-JV!QX(1gJP>;C^m|D^h*k5enFc?VZhzsmpfwc2nC@+ zJmEq{`=BUhaWjDa$@SHN#`wI@i=xekZTGv78_rdbk^g`?q^~kc1?|Prw*+zVR7wS_ z443id9qkJ`cg)t%WszeOm18oDd1q67K8$&j%)?oGUU+NpyN|pX$UDuDcQiG_f(s`% zoR6|cLyJ1e#BI;ZY*278;+qfHd1NoR^_4F;4ogRYo34oEP=(Ovw<1wl;_`PXI}U>D zCTi<->)5lYjPX7YdCUWMc?`=TyI7<47}si+`Zsb}mxdL*A?16jaDW~#TzmZ2vmTJ8 zIbe&jRd}=5ofL9-mlSMRz+sG=z|8xG0t6bL7`mxi3li7W3>oB4-`#WgX=j(gWvKJB zUC0B39R(=>e3kztIE?!G^m2@)HhyxCHF@hk7wNF@WB{!p?9F}AtoWrG6xZehoB>S< zH)%u)-Siv$MB6Y~#?c0Y&b4Ix^LHHMwyo6%Vj=EG!GY`}9}_N>ph`MEG7usp!b3gJ zh2J0u2u9kriK~6bM+Nh5&nT+U!DWC!03H$o!3G*?DTxgX*!*e)Rs1sacaPy%23Kki z0yg)B?W^s|NCAI6BDF+I8g6GWZqL^r!M_B6qe%$3kiB9yi<~0#htlWAI#GKAJS-H5 zJV6n^85o}W2_QGGWb&Ba$H^R0o_7xKrL*1))F0lLQ_1t~6R%RJAwwZjOY2H=$JE0h z0*rc+{)$8vPZ4|jrY(#9ILf-H`258%*Jn!|6f?E06C=iGb|KO#V5ze!Aod0e5Z!=Qi#fB?a^9AamrCGuiu1?;qY;^S6wD4{qV z3hJUh-3c5jAbb%JM*)I^u-_2^yb&kN;x_*EI5fg_hnFqEBJ{O!$~`JIL=*GX7j!V> zR@T^at6n(Yu{Vl>AFFY@=74mI1Hb*S?}v!TKF;p(z`~8{zVD4tNze3%+q!?u@Ma`8 z4NA}c%_eaX!E-9^G@M#+h~LI zjctL`5OwbUWTiaiCeWzaeifoMY)6ZGbO?&wol7vOq&jhvOeGrB`QI6?j_0xw7R>!c zgEPA#w1P9+<@5^SO3ox9^BmfK7aVg!Fw&byXRg4N<*(5n)m^ZIC6 z1PD<-LA>=9Cp>FMar>EM%)g;S6m7L>(xPt6a}Rz-CJw9M7fVPgzRRhwyyaDr2jS3K zKYm&5fvoPK;hXf8|)CecpMUV0^j29>>;Iayv_)Dc+T>(kmS|xY6QH zx(1b@Pr61cdwJ@}mq!oz2l$mUl7|~EIP8Hj!4;M9%gD#yJL{m;oy?Jt8N4@298PU%zQbcJyjv zp@w=A)G_ll^R2Rj05GXs%2%A^{6&3=%%G(R-UHzHIH|R&OPpPIxT3jF@unE^uHGT>Eck^X5o*H7X*Gd)z0Rp^(AAmEOb9Ipy=qZ>!!+o ztCCtOB|w7$-wjsDUy=$IlkFN4PD<8}a{|=@`r%KQ>HTVjK)k}Y?D)^|0jgyqL(fR? zIh^K+rd&S>SJNaC{CE$b==@uv84=}L#Yi~}xi$cLF!k3sgr~R7u1DU#{4-@Z&nJ+V zo8yVv{p&NnfOSfQ%WXTvXB>1KtNQM-FFn?L%}(mp`HZji%XXgp_cuU^6A37B7XB@9 zcBD86GX_?{zJSfZu>e6F`}_mS}j1nHmkkYj%I>z9!{^10)fQ0b0e z$Nz}h!2Lm3l)o);f|fTAQ)+DVQAV{M6Cv%%MVX)+;<8pp zRgg<7_|^baxq?r*A~hcVsq5*4Z!r90(tY31J-dmCq5TFg!#T^U89wYRy_63{JK6hY z;xx;*E=|#UaG|qwKd=#1) z9~;icZ>>rFL}{+KdbLwPk0_#*VB%N(M!P$qnRzJd@gsmBzjNpy_IlcC)0d0UK!1@8&OtfGEWr}K+YQ`*va@JA> z@dm^%ecl}wa|SHXit!g=Vi(uk1kpMqtuWP{B{`S^Dv{_$JWxhFXBSyraytf86=}(E zG&;atvRE9l^N*=58E&Ze%F>Z4=J{#R1Z>bTIuQQA+hE_v^4!?%AqJPhRE(DrrB{(q z_p`{M(Pw$-6V(K*ZS}m!GSi1}bFUSRNWO2`{quPDC;$ z04Z>0W*q`Lk2wf^m?@ms(ZGiymbtI&n(u*w&~xOCau2H=kBRY`!wik3htrASS*>o) z-Ly6R^qXnm$uU*xybhmVu@7+1EmSmFFSM!61-GgkFsDSs>apc5YfwY?R`O>?Knqx_ z5}Z`73#Tn8)ds)SH+E`F1Oj#>cNB!s{gEbZah~!rGHmE$ALs% zo6(d2E5;=Go1p@KMNz!=hhP(LQ9MH8$X9Mruk;wQkw9cb@r1LH)I9N&45vVW=KBtd ziew?m!R`WoZo2c40&r10hCPrH<0(*}K*{+zBaBlIc-V#*FuGoqPxvr`;_W+`D-#~i z?}ZQg4WS+U1cVy|`FHTK{a6kHY;(BU|IR7?NiCYfuAZ}>BmXrA{SgR3ubu~$gG^qZTqrbCDKRw*=aVHp+VV(`Y zcyDudv}&NS_a~>-7n^_4zzpQHC~E1`zHp7krLdn%uobu+{8~)%>Zh%3qKXU=dX`^C}S zXtlK2X1Ym87V$4ScX;zOQ50~MlNY>xo=82Em1XmZVcr$*JeHZgY7+RwAGVLD!tdYs%vh_&!$={ZzaLf^Q!*Y z-f}wsKV*zk;hKB3OmJ4xnczb=Y}z0@&r}#8O11MT!V_nbj-i@`&|1Jo#<9@&3f7+& zhP&^%$Mi^@73!hy%l!F%H^#t8hgA*q8nBEwFB7=O4!?IlP9NoG`W__DI>$66^rdR9 zFq5+Yjym^3p0%J8w@{F2or3-KqQGn1Y%45d{~Biv4O7F|@ak@FGxJC8iWTJhrKH!! z?r@zP!6vR|s}FF@jtUt0PVqCJRr?J9=R1~q7>y_fF(7omjq90HOCDCo=c}Q~>wB{r zW;^oHmbVFiTe7I8Y+^mEMpT2iG-^iNGKXb?Q+J{XFJY!fT6{k<6}_Ifwp{Io+wqg^ zF8_y=g*l>Blkc(o)fEk``6x);qa;Tc{CES0X;XT@IvgF9s&>JL9&{y_w+N>aeZ1PF zXS`*!rOEY$7m5)|9lY`aAk$|-ZekjQ`^R3d+A6o{%y8e;8iJTo{J1is&x1-G5Eqf8 zPYJ&T*x*x{$I|qBHiWw!I1ujo&frwzhC?b zmjU)zThn6`b%o)vzU7PszOyP5@43oxMgtut4&_>tMlo^OtJzPM-A+i|m#gbgl1HE7 zw6bE};Yg0meZ%CG*d@u0c?TXONnVIHX;1AKowYt@x6{DA!~T2MYxC9|;9zQCKq}Qfe1IC7HYbMS8Q3IKlGW$wK6gI9{TaNTqi7;bid5b4Q1{e>1n~u5S0;T751)qiOs`RcI`>4c zQ6=#Cio}4)-7wXN$4ufE{Bw2nlmSPmuDz>tJrmJ(E~tADE)oX#SLT$+2x^8UUDM}uSjvD@tGHC+z$xXrUel*Pp!p#eN=cqZS-^hB-RzG zPoOp)tSD|RVwMx;D`n!|>%6=lFe%Ay?;$15LvzN~Fw7Wn(1tR4hQ_n#;~r_KBWhRf z+0E}!+iYz)1t4XH$#LiBwQer z1A-Nb7XM8TquIE=(nBnd5=r%rj!Be2D1rvJhBRqqqS!y*CUZun$hDc>Hv(*p0E|o# zvscfdCv0*J$NTKsu=RAO^IcwbUy(~0Wl{CCrCC+^zq)k?+bnYJv+rJ}x0eRBN|bF; zqf|W;02^^no8~sjEKTme3Yg5WSly(0tSTHo*3PbgpZQirV_R>RxMX1jm`h>%p4TWT zkcsygF8%TS=!=Z7vteG2%xfR+i(yVq(Em|o#kdhy` zn~CJ_5b`0v+g}#Ug7Ctr#`#3|Xr!_r`zD=(JG;uC^_J^Q%aWnGH-rwI3w zvYir1ia4v13SWh}LWMb{f}J8YIcmWko;KpjSfrw7FI9*`%Oq2O~_cGye1W z8QMDez!`4P|D5kXc5?_Go}tXr4cWNGIY&*lo-TC}y&GK1z2^REY=a?nA^z&YCx+|V z5M&G+($JjD=kpEBkbjTO>hV1B0#wd3y89GBcRTNVO~xF32e+W{7u{|5@de#&WBC`| z4RAcUus<E=a;i$bBO3J+VVGX{r<&lwjzTR>&6ligk-C^dYZ+W^| z%KaqcC4O0VWKRwA@chFJnHw;f+i&poP`NPDo-c4>r}+`BHP>F;Cg|rt8f1_I?IYq zZfSVyCUEAr0XYC4jY&c;t`pfp=(ehMmgO)t5E@V-b!y3HiS&+W1%ED1Fs`f#QAtXH zvn*e$b2e#B=io5pA@O2F4Ed{g!iLvSTs5IyNxvEsDX+YeR`<|nxhpUmS?@NhoPzkX zP0}LBX+H~r+6)b*a*aB))%AH&`Lz`9+wnRYL+_50N-xZzSg9Lr$269kuSU46_T4(| zEoVvAybwBK&_B}nb}aX-IX-kWCg)TTB3s%U&&hotT$mU-Z zW7sVg@IWz}w7&9d4*q5lbXl@;qboHWV{-idMlE&e%krR$&*LedkI((WllkMr!iToK zV+?7!0K^U0Gb7~{Dr(2^`lclwoL$r0bo4&kl*bbF`9+r2(b}}=)&k{XBl>=w$W}rR zfvdOYbMSXsyAGsL| zh{h&i5x00rjw*Cj=7<^)-j365UN16K6PWrBu4U)Vs?e(w2!Eq6WPKxa^x0re55>*D zu$%(pHT-ADo@lS0v%8%U5PPUdVksnAsfe<2r_?$6wNl3v-euS_`bo07MH;EtL8gCM z0qp7JXTmOaUOU2ZZjNzo?vOQ=?Y7b|=G-!LOX_eb(1jMfR)V8~jPsM^z=*3-Nf9d| zRF0$yj;A8c`5r`f;L52o^O_n+g{kr|n^Jjn<|>$sz;!~d`q>IJ5Nhoh2{dMCA!TbV zpPcUz;FO7BZiT1usC*b@F?FePN2J}`yDa*pZ87!vbmgpEqj0gH^##7ssz$3k_+g`3 zvhohFQMzMFd6Olx#ko&j;x|2pv|Ti8e;eHym41X4EB&ROZ2)hnG(<1ETGLdntk6ta zE?M9)#j}U+^rdOErO8wTP9=x`@L29}1wSjDDN`xJ#!@Ku`-sY+ z^u?Hp(O9+E|Na90>l>LLgC%WA91@i$KP1V*eEDIM`3*c1hT79B6PmPBD7rjyi^Aq& z)gQ%?7ZAI!6KFZ);xxXNe}POUtg?@MezMb4i{VkaW)2fmswLWIAZ4S z=j|UKK=#zB459d(-Yp@K?YyQafK%o^wKc(!Opo#x%)OXb)O`t1}+1nZ#WefLtA;|iDQ;;cl6OnV}KRv z_81po=x?Mpq_YYda+1(94G|g4jfd!8pz}%gux+6u)lfb$VO^pTKj0Hpd8>zm+xK@s7S5r)WFD7WYcfWc$9!g7 zb$U_BS03v_2h3dU7LK%Gt|(WVf=xcW{&WvR=EY)$3})RltYdpUraD}ac8T+3S?D%# zumnp`Tzx<8aeGwG_iOw3gU>x*X}Zt-q3Yw3-#}o6FSkHiNi?}wJ?TX92(^|7^KPfX z^(O`OLM5w?HyAXk#t`s+kR5({0u{KSdg^>zFE_#jA_$J7uXo00Fl;d$4;Nv%#$uc-jyVzyR(ES~?Qh|~ch58eEH=F@vr9F*7pu9ft z1^v8EmHSyT(=@P=dUX-qIYTrzLqU$RAU0c`(y@?G1%-O#b6j_ouNb+C8f#=LiW)~` z9Jo5i$yh|^fHC;wwV)m-(%(Bpzc;GCF}ONwWR56rH1>xZx5F>t%h{pGD^KP1Pq#W@ zUeL2+MP>ggC6qp?mrP0ivAxVl_M8E>mkEjl!1jW4WV~vcb@~?;Pvi6O^0jsG>*7

HxXw$x8`iKb|d^~R)9{VU8`5gH}uL{>VmgP(f%j#tp%DCm8;^3q8>+Y zLod@>`diDq%#DfGyKt*o z`iNp2i`1FfH!i=!M6VMy_nx$*tsW*-P12FN?>_Bf_!O{AQRVsdoA=CKx<~xe4KnBr z@aIB{Bu!}Z~+nN`HC z%VU`|6~9O!kKt&YK>5jyj|4<|av}3$&EAS+&)U=Zai~vx`A~Xo)oroSQ<2i8u8)Lp zIm>q@r}AHi!0*0UUDVYN;Z;M;icikUx)1yG4yO*O6Dp6l39%?GflS#HHlZ57*o!o3 z(a}{5<{qXC`aY}I|2kY~ZqWb^X8!Ejkv)gg(iE0Z<2>Cb`<~XKT0>VprLv#TDsAEI z{Eo)0wdDuV{1BJ>tW=A!Mz&>5(;ePxnG6f)>?cO%8IQ{k(`(c3{PrxpUhk)cI^3;8 zUw%0Kaeiz*lT`CPEBVb`VbPob2CG`y_k>o$fUs`;%?|?s))mx3k+B@(iv5j<{DcpqV)4TGwXO@=n z&7t`#8AK{*!ErOqo-A%^=D*IkJ_L>~w)=klsarCJA8u+hd%;V!Fh}ka+ z6z6_g#s`6MP-aTI00S)))) zo@`#2d0b@lp-qE!m?cXg+g_ub^_qQW83D&$;P&AoY>|@8{4*RTN~T9z44d5@8_xaegA^*RGo88_iuXY zOxM(O_to75;ZQ*Td_XTc0gij3O+{MSuWH64-T|FYGeNGBX^1TNp6ieHDr?1MxZfz! z5MWSpQ0R17J4(v(nm%4NQlVpjpcM-Sq(HnHT$5>0#DT)J0lZgZYlN6l-1$4H1Yn5$ zgf0B;v{@f)=TBU=>`zJw*&dx`zRB2)hHtL5>gB35jkD>BmD;6?*b9;(d<+Yxw^C>v zZiukHL;bNF@~1LO?Prg}AANgSuYCFZTT!B$*B{4;QZb{ zrlcl%WrEk!H#Q&-t-NhYf%bBI$6yZ6@t=F8JKpVcGbS&BX8Wn!@vr!9FTby6$bHYw zzp_ehD>a>9PxTwM7eH2%Q`=IZr^D$)yutF zamx9Qee&{3*n7X99smbWc(>>KbkDWLYFRk*@7m_c><@Sqr0&(BY!|&8z-2ix7;Q1H zG`l>c`u0}zv;ffE9=AtRd>SH=e?2(((0zIu|LYtvfeAzhR;ATgZ?YkIzE|7JXcywX z{1P0`G4K;Wd3bRWJjXG7?@6(`naFmmudjDwhssk@=N?#;7n$fBP?JW`F_T$EI%mjz zv2(1f*twN@aLl`_OD?(X^NIeSV0In~iZ zKn}l#o|nCvMMfu+!#bdsq(-|u-50|H}kHTC%3t)CeQ4F>0pQ0 zRLDli6D^-J9muAMR8YBvr+$N1B{`no{W%-SW}jTX1hMUiTPMfmVFzNXPZ6t!+of#o zK2q84#x_2qu|<7K2=AouvK?}7@};t$#*~7uAEdt?hT5Y7!Rn z`gw$(4h@Fh_`?JvtLY}(I&)nlUiR8&js<&abtd5IU9rRD z6Hv}`QQy?hM7h8kMd^>>4&y_1m@@e3^`y=U{=~`!KRDf%rVZv3OkUJvd#SkyES$~v zZVf!5f(OD0Yf(>%p`nUNS%j7=gO~z(=*Ov#@GmZF461f{eh=PE{Q0qGBzLd!{wJAH zdlB<8FQLyaQ4=&ca7X_;CcA6Y=DazXaGgz5vR!Jw?Z zfuUzX2KMSRV!}OILeOwe=u>Tj+qVj9vvJWA(TBqB2DA{;hXT-csX1k|Yb%!UF|8zZ z5oXly<4Ar#llv@0w+=5dVver;kz%B|HY@w1*T!|>shHiFi}EyMJ%08Wd4?o}A6(5! zJ*%o9g=1O1hVRljGqE9^`iOMno86`eD03(cU_dC_xTcmlBMc@UEv+c9F6<>Q*N|k`Hj*83d$2%T-+ksn}r8YaHpjtM9ybt97 z#u8TTW zNiwD@*5n$Oon*+ZCd|Dog{iAAt~XP2B~=Mq7||u5Zl8yyJfVF`34o-e)mNu7K5VYI z{C1--Q*zGA0WTZT%; zMiAS(0*Q*8BI(NXW5Z5ygx_!1B?2l0R0(NRIZ6I8#D)<-NVm5tzG0Z_r7MU;2!#X&1p)>f03=ugF1S4jp@m(u`<+cr z3kV%c4G6Xeqy~ug3u9pVt@Wyb@kW6DaXsdt$DZM3dH|HMk0X)v?C@$4bo{LdvOxf& zU<#o$5;@ejpQGsLv5pjX?H|Mr03eKTjrtVlM~r$?G=LeH6q!s-M!GW^GA&fn8w|Rt zyl@?%hPv}U8puF2ERyR`UN^_6y)~aA%jpJUceo5ZJ_(H;bgkj@1F7+#Idzy;eSd}# zWLhu_FR9n?iX!-+lu8QUwJJo)Rng=VHEh!gbF{F%o5bdn53Ejuo2lg4e6(IlRW6p- ziXvcLl6R_M%)v&}q)Ky7@MT=>^x>Gd)2ec2ti#xL-f{tTRJU^Q6*CB|Pz09Tv-$SA zERWo{&XL~1jE(&qmz!UJUehz!a5C-Kr8wxaWz!K>a?0#V%`)6mwzhca%yN}nWPz(* zP@?vTdvG#^5apb7Ir=eEe``GW$7O|gv$;_2a(;a=k|#`U@if5Nrq;Wo35{as{9wYZ z2zSr9S>@B$rXTjg6FbIxaI|lz^HulLQ7-lBJR*Ci@K>ZuW_EbJEmr%3pIr?6rZtwX zZ9(CL)B!{|#JPM(^_nsAE|mSx?>~4je(+F`Y^-a55}wB#Pv=WTSH<(TX<4K`A5%-| zd~ej1Gq{JTS2(5)oF;OD!uv5Lrlt{4taC~oSiE!6e`p~p&irF`!W3J!V%EkOwC^z5EcVOt)GY~P2MYFbfQ~s#CRbzX2)4GG5i6=OM5>@^ zOsbIaW9IWZYVMWU4(E%M^`@G`;LLksm6l^D+e`V>takSp-IJ)AOYd721HysSrwh77DD&@5`S%S2b}E>_u^YEopfK@j@UXPvCmloos)0pk=jGH z@|zPU8zpODcxKF%j3Nlo1YXIRlSDIMWS z;MLqa!F_(*oB*Jbf|w4DV+*soeXhHVcav;HbG5PVa?klEHfrl!3QU% zUi_^ArP`p#hX20ZziMgp8mE&>Z8&cBNFYD5L!-WF>2=!}MioMMxTz`vXWq0_4*RE| z)QJ36{WC|z`mJ(6XcpdfKB9GJk@s<&uAR+F6nz@bS$;T+%2OZlgdb}UkB~lJudwG( zf6i5ilqz(5gEz0A6_J2s>1>PbTC_ik8`f|Z?qbM;fj9X+K2###g$u1O7PEFZ+=f19u!~F55jqAWa=<}AT$AR`FezsXYn^Y5K^vGsEPtIka1~8hQBujkgL}5Zdm>iH24M=4ae+_?O6o382UIX+nf$y-B z0rkvN8tvj>IOtVZdD~57u_(Yvf zmKeJ5tUX*49Ol;Cfa8Bjc7>rO$;!0%PpAPJ66MY#Mlbnxpu;@9er@cZZHd+$zgzFD zUXD68ye~G?#e<{g6)AkRS3juw37bsHnN?gXg3%?ovWimoqpCM*yJ$8)U{2Km@p2CmDMEotwUSr-Yen>QbYEqd#=NgG!$ z+~#TwD%S_S+B{v(O)Ky|7Zdd~^_MfXPaRwE+lY&`RmbEGd%k#p2e;-4x8}OL&5OiL z;F)o+6_>Ns=c#+h3^nS9y@dDZUkiUwfW@*qh6N%C5?pE6im5H*3cqg(M(r!{i0w|^ zn)5eym-t+@UBP>0KGo0czNX_?wC3*=Bm?HvZZSn?-|Bf^+`TH>6ChT@(>ED50Mp%Q zcn^%H{38-CR4TUXol(qXYsX0GtT!eTJ}>4H#T_}_=dU~OPCW{qNGTLah^v9~A>7uF zK1iR)hasIMi+xrtCwb=4(o=)^Z%ip?URn;Vsg}C0k~E0-4t>6=>?iz319y|yi$37a zrZQNdKAV1c=RX-#XW8*8GSs3@eoo0y@PK|hVJz{IMJtto7@_=eUzf2{UyZm zbhEa9K5{prmwj*&xun5u+=a*|5f1}la1_Q#ZZ*9rFQ6C@>gs5Vz@`?Cxu06z$!y5H zy)6KV+fv_|qm;Vj42j!e=y|?3ao}>IR3yhq0nsuFf81In|)jq}_K%^SKCslEeM6xYNF_6Y014)SM^;xrH59 zd%TZREKsqnG)Jh-gsF5IK{ut#LM($8Uh5$Dy5=91hJuE}9(Ed5%w=?wc1`rsHrA4> zGIwsZa_0xc@5XYCE!GIuL*K`JEc}Pxis8p)WacK8!B(&H+6>laDW0t4=5hypm|U+W z>~7CyV2IfjGjbiX6qpIvf+S+c7>mD$_h7V^!Z_MiBT-M!t=LmHjkfSS zXYbeXE*;7I4Sac8pf&S&f_I;_Tz9m+8K3tucce|To^*#7W=rJ5kIt9|qK7NGLd z>l)8go$YgH^^nd|y``~a=hfqV0?C{FN8s?G-DuQ)sblavf42{rcktO0)-Hw1iWN)! zu|Ow&w%y*etlA|l*LGAtc&0$HSME1r^G=v-L4t|}1%S#YrSz8a4nMs$fj?u>Bs!u{ zATRrd`ZvI4;Cm4}#Xh{yyCdg3f8?)=Lm)>_hzvC>t)69&A#}zjzgqB!2eS(Pe5fYg z#jQ-vCmKW-oxs8;mV{SYexuf@NB)#GulCHD^(a|AyGs9J>+8d~9?6Y_%2jb>^@CaP zc5jjzBy}iSF!aD?td7X@)zov#VUuL;^tw(&>CYf%G6OdhBc`Zr(o6j&7IuKfr-<7F zcz*5SmE98k^p3&o&69kMQP!2S_ADZ0BY+#Ce^W>&S4gFXO#{iTfmYvAyPW^MlVOZr zbqhSwvS8MFj#9D|C}Px4|nfD}jqC=Q4e*dYdNMCRyWbX20g zSgJiEK)xP>=0uV1WSZS&Sf=_SY+8t; zR6HB@!yT)k?Ld9St4p2tNxTlW|4x9Z*XHpDitTTM8{BgXY8)V+sf*1mglo!6kl? zN&M>Gm4I`gNKGsG4$kLJ|_rwWGpvSi0?$ArvJ=xf} z`WA;YdMT~2aDQ>)$9`SBUa3_KO(#LWwQ$@(WtX3*vp9wp(r|mZ_WMKSPW7DK6f)gn z?xnK|AWvS=LbJJ%{JW8LRJ)Wt=C`;hDN7vIXoRsSb2aVe<=*Z@W~nE^z-yRF>M)1u z10#Nx`0*1NHO*aFll!?0b%G5H!oh;XtYvBjBWMJW#oJl#tl;{=KhCqbN%vhbnV6fyx?FA=O^N^>qtMXEd5)0sI^cKQk4jG};b}u;4rFM8D zx)=UJX`$dIo(-f6V33fRZ0~S${ z1@n2S>Gp%y-5LVOBbwCU-uEIV|CC6GKZW9)E1|f*<+hafY11(L{6Rk-#*$9hMv|mj zpVT3q&*}`ob?_lWS&7zZh{p2Vyr^qfc02|2T5>e!U+*`f-hXrvF9qkJHFVVAfB{$< z>-N;)D+SKnG}p)?H_k=8@IrNbSq_~mo=AMsgvT_v^*Z#f58-$_cRJXM@7s1fN?~Eu zW=)NfEgSz3Yy8vs3`@JSIq+8zk)iZH!tTv95i?clhbBO69d8w_A=P=a5mrO~dw^%sUOtjZr5!E`rD5#h5-}|f zN6IiCvMF?h7qH2#|Bat;+(luzFdEp(%Cp?#Cte?dK}e0zGSPWc2m7!iO*YG5f^m#Dc6`o1ypOT>P7DZ=K64E}3fVT z)u87*xbMLQ0_X+NxxSD4jnRd{BctpjUX-po%!7c{cssO`%MGffZd~zw^gj3f9KXXc zx{pBch2fKUYYKXyRD@@~=g&*zHnC&;T+e+`=zv0pPEB8>vP7B%&3tVJwM|s1MvuY|(f>@GacxpR6rF82zZ&`O z*iU(9&tzLP^@p2ruWO**dQ;ld0$BhqwE@7!#E<8X8Vm^n?bA?IwObWG+(+LhR6a#* zr|G;C>e$3uwdEGX2LCAQEs|~339MR@7#lc%p@2{|gM_h-=oR-#GF)j(Z5MHe%hUEv zjMP<-up&GP>&?1ia_WV~)*Jts6-Wp7D=&=vtX7=@{tkrT2f7XP7oParx}CGvi@@}c z$4tTT(FCs?x=A}S*1;R|%uZ_;IV7!5Nk4azuvl4@(n>3RF+O=BPbHtd?SFDvKH1s~ z1yWLr^v4zWKk{W#*W=dktyWl>4>3{Irll(!oz+?+WR;?phVKO0@L~HE5I6d3M4N9u z8%y6#iZU=*aKGZ^JK4D;`Ax%HkPee7o*t$TI;06x4>f0t5);M7h>MewqM$&Bu_lTy zRUwp4FJN&6RdKXnkYr2}&B_AUH?1O97E|KQ&aI(GCjMM#{7Cn~OFF~!EG#Zo8pcRy z?%;wyKR5(B2mMif&9e>r2c*ldTJ5NKLu1pa5g3By3@;6-g@w}5+^^ih%j=BhG8&be z>Jii#?cUnJ?F?(v=INyWnjOP6tT4sNe0H+($9##h(kl)ZArLA9A!yW9s{|$Ps-N)3+*nT~lMr!5$2V=Y~QMyVN!_bLmRJ9G7VbR}KM*@7(Potfw(hh6+T_Ta$!&&s{a~C-Oda4!i_x63g z$Z;Bhz_olvklnS&D29&Fd;s}tQ~d}9{*kLAFdzekiDiou*dabp5RR14+SSadkpD68 zUr;q*OeVkEuRZeVJYX_ivD(Oq(Qu+k)&PZc0^Jbcn1=rXdd^y8`ume9|3xiN6qpcR zO}h@>?>S#(MXG)UwiXB*1+o3Koz3c`a098P=Af`zhZd}h}N|TKE9VY z@E^_mZzd-&t*huVsJa$dg(MYSpo*y^@xK=OZ`c~VW_=|Tu4mO9!(fHh9qxaw1+)kO zH15h0_@Jx(Zj@s+QR2$~Mz8waE#j?DQk{)aq(WA;);+W+6<5xbDA<-2bnboZh-zQC zESZR61ll1WgOFmxA|1B@!_uCD4y;jtP9An116i&RHY%SK259D>mw;RP zYu<{{T}l%NYO<#SI79xMnUlmLFSTuo7>ob?ZF?&#g&oD3t(rHn>-1XiYQRQo?i96t z^Ll5xmSPxDtlG`NjB4m^@>_9!enG9>!)rAE3U_4sRC9iJ$Yz-{_c4I-yQQnM5LTAV zFTQ^};+9*F374M`y_I&6kI?lWwuQUjiqmT+t4K6RtXmul`>;0%vBV+b{Ee%H>h|Cv zTJq!L<4xs~T{z+QCUh$bq#A2*EwEnBuYQZ|q)u$c^{4Y*5LNB}$bY4I_xMe;Xshj= zg#M|L)yC0Q$L%_z12;cKu+_QXE6Eg|ezyw+SQq?L*!2vZ+ab1yO> zQu8JG<5)6V-*%tbC-Bxo?n-n+_WXN1wSOX_p>iuXjb|Loy8dL%Pyi$l^*{b0#&Vp0-yn&Z9`#am5^ z>=@@IxGUH8UtCqpHgnf|<=7s3Ms+ELkYum~LI3YC?aY&PM%PAaCAmU-K5{!FfiWyQ z7g~e$s33|u*RIa6L!K9zuXjV$OjE$Z4$t)(h;wi9jHOeS8{`ZIM5`0`T#yLH$)6_m z)}gol6jOY!EZo>z`T9Mld<-1qBaR!GuEzM-C1}ks4o0O)f|m^ewP@Y64!Y{a;UfXnl_^cVTbyGd3Ba zGE+;;jM*;wN#zaL@cYgh3I7MI-WO=&RTg{*?=96z^0X#~g=S3tLS>8f z=8Itg=$8m1xhO>esvK0z7B4~pOG;9Npu)-@23VBZXfRJ1baYc@QhHFkI7RI z{RPvSFW0$E-2(GvD(ePS+Qsk5+EzAFF|%hUWTRhzA4-aCT0A%Xknj3K_eZxm-E0_m z#G<))PyHV8J?A$x#{KuHcu((30w12m-}6xAVJN|wyEPzZ=kXr^rTUrS;e|^bQ*8sb z6Yio`rvi%^`NHq^j)li-30gLqMX=*(iaK-i#&ff3_hQ0ok0=(Ic0~hFbLR$B2x}eH z%M0iJ-z*yn)ooalZHAV6d@KSRo~PW*qrFRwJnxR>_SDmmn^#~vaYL7a4^5TuA0<9q z?^oXdKg;I8&V>^Pm}_Rt%AMxauunGbZ+L4&pd}U%LE&LUJHuICSuamtcvo8s9L)Tq z`0>4PxI9-m(^AA?l^0ptMfQ^dna+dw+i=L!T!fkyaj*~Va}ICJ_s1`@cnm+Z&kK#` zTpj4Ieu8Ldof~+UR|dV&8dhsK&UMc>Mi9v9q5Xc!qSF2t^fXWqu6SsXpG1@Bft{c` zpmZo;F_fDii~|HMa0yPh_k8P zpOc#FOATh(rLf-n>lbp)Yg=;=eFNgh1(Lh}+0GKqfGuUPtH&c_Ng~eN9rwga?R{{c z*uR!42@-fs$O9#Z@cj&G%xRe`(OT7!qko>Fc|D=Ece8x17l1x zlkx{D(Jv&CP-5YkE)itVP(%+P`$*sceH76DR_`OrSAoG9QwSs$rIqqUJ*n|u}V6s3XFBI|#q2;TxxKbrbRi z@Idct%6H=+LEX&2U9Ax=E>vy0ra8>0@FGGp@~r5)k!NbueD*K#Hk##?llu9xQg1OGGu=O(Kz$F{-pi}=Tr2rJ`%kFh zi(m$@;QCFOG_G^`@z=rV1UGV9(p1~aC-T{jpTt5N9l@06%(MT~% zYUI!6rF$hYyr^Zh>?W1Vv7ZKdO9S3L+*IW`bmgEZc=$T5{^;n4`XWr-tIgnO+#)>C zppubRR+v;RN}@kqNx08BMmvS3X+0P;E2$xlf*fu2h|<}0{W(BC-ke`K6=_V%6Q3Ht z3G$XNpj1Sq`h_a;!X@&u3gqhK;4piBGD}~K+`bZ?=v0cHL_#N&{hs+u$y2|g<=|Fd z%sY^%ZSLe-s29yS@Dlko-?iI4rt~CwNCWMT-R$%6=d$uc>6gZO28NjW9zq|(!-L#8 z8AE@%{ATH!>eAsBj%|U}{A~BVnA!`NCrR=RPQsF>2gj+4;c%)llj0L$K^6C1G$L?A zGp##@-VpBz4emnQML4rIwcAy}fbQ0OElRSgTNg2GvAuVj;UEd8#7oS*J?Dwi5QxN! z714=Os@w}JcA})@Y_W2;J}ZL;K^>p5_sC1k1JRk#B1OFhfooHxERl7gg5pGY#5(f# z6~a0}btnzuI^ot)6peA|*7pu|@|AqEda;9sg-5AIl(Y0WUSdz-$Ix@+6_PqJF* zrmeI^@4B8JZ@j3=uCzDDx~~fFev0kMNnXFl721A;l<5QN2y2Xtl1uG3@^iYMRETuH z%p-cyMv6Ws5|~>9h7w}tjl2l-!xM+;~NV5D_%q4`{TX!>MHp|S1$~g z>e^YA-EVWejHvaJ;s?6F#r>W$K>Dy@yzcDHMhstOQUrQ4BSwJ&gJr z49c9Zqc`@PvE7e&sZ;Jq1jH#u(V0}60FAX8kYhxveq*)HZ#7)ts<=1RHV8A zZ&$~SX$*B$rN`-D#b}Enx9So7v0P&pH?|8<2j}#PiI<*7&V2YcHTIV{0=s4DnXn?%4H=*cp@}Q5if;!@b@9* zX&#ssVX9>5RBhGQV*cMxXGn!yQXO9yaKR;TwhS{e56eOOtcCZ!PbuY zQ1Q+BB$U+BN`qFY%pF&{A#@7bPHWL0(+#rtJN4i)He07Sn(2H<_pXEOG|4zGGA!MA%6FJbUd{Y)hYGJO`eDrIpSVI%M8h#gBTW0tum>sJa{|nR z-meKpIQ{$uDR*?>REKq_YiVR$RQ*RQvxX zWmAo_Xqi~3E1VPzJTREyu!7-)f{FTL49drAUR&TC%{-PMZ+r|@4sYNORv6cPF%jh~ zhsTn)D-P3xG`g?VBMH1N#&#D-x9%4;mLyn*B=yaw4Q{h`Y3L_des(_RKQCmW(-Wfw zj*h;>5J?v;3$OMmoEg~EnTY6NhHCVc+%dT&i8~+r9J`_Ta_!mh{Xb&{kg zYB2a|co;;S#@L;P)cRPV?PzAvC}Wo+%D=aI*XR4~l$#LAspGDyZgjGJL1beHNBmGk z?=s@u&uU$;)>T19tFB$S%+|e~dH~-Lu{LB`=$z=WiS}__gM)O^7x~hnO(SD*3U)vD zSl+2s!RZWZzdi=nJCACOoxti(5c^w3(5Y(M`tMo;NGmtUSLY+568soEu9g|sJs#`! zh*|K|Z_E_NvLhKWoJ5We2^YV^xTW4Ik~o>XjcQj%V2@1%++%r1gKlSC9q2ai8MK6u zi9EBNp|{fRL^tOjmNQr!zq+MlLbhXc?3IbZCNdWd7OSe~-?S<^+&z;jF&vdyR9POS zq?!-;2Td=0G0Uw_o7Ng!0I=HntujV!j>*xT8O}CBFw9p&=!Tn7ygxSO?zQV}OR!e2 z*LkIXyLJ8wO?Nz(vYs}m#Eg$R+d$ zunnF|sW0EYnjAx^?>B95E^16}eN1irBj>ec>1DQx(yDkw3lM6Zm1&hWbEqm+XmE%{ z5jhhvC69F=JCGbpOrW7!iYURD72yRjpD6L|PVZx+&2voGuLEsH|`E(!ODx#T3q`SGg`-M85|O^FDct`WAU? zIx0=UqCf>g5D5tj6S>T^E}B~!Zn0{wUAF}ztOSASrUsiP-oRN%XE7W|HwOzO9NKqi zmlu#VT9TK7J`JC&8sMgtVkO)ctaDj&}t=5#WkJC;<0RY1+|nEda8&)*L7*?;Kljkxz(*dA3_&|APR zO?jSa8)Ui>#wS1`RdA?SRjY=NfCP*nS^RGN^l5nzaY2WBnCz@zzaPrFTp)7WBI7y| z49~K+QDxh@0~6T6{TqkF9jA+GWnJ?-R~NOTrQ=pDP3mR(pP=W=GK;IK1JB3_m?ca9 z)O_8HS-XzB#HII+gJe~-7znZ_7I+Oms1=z9_}u2iP35O8$dYMgvqxFA_Q5vDzpNBY5 zHuNgy4i{tUay;7O9yd|NBqV_s0G08CAip4vcR}jBKbhjBC{_@qPo)l%6i}#7r6_i0AHfaUxr6x~ zBFf)r`(G^XVY-USh2Z9=W6{g~%?2}HU;)ldFwsRZY$?$k+~6Z5fQrTUDnF?k@OJEMl9 zI54-PWMyU3cy(3}SPeNmYvbq_`Wy;Oj_bQw2!hPmJ*5zA9~(ki;$R~$tMAX4Le#tM zA2c-I)Z9-ukH>5bC|#N4`F*{fR6nT(X>BIMNh<6icamRTC?4aIcVsB9lzqwpcUO@d zitLsrdN27jUt4fu$N#{Oo$+?!ICIzTNoRq{^JVOuK;OHVxp5uCdrakWl!-@&yAG8PfC^C%>b zkrneE;%`3)=P#~sbH7y8ih%8O`a=DGGyldWaXhw$X4W#HQ&5Hv;33Nx30z>=b3&2$xhQe>k`-{; zyku;qV~(3|U5y$4$4?ETbSC*~=~gn#s9Mb$YUU_N=$8HQgjb|cl4sH9ZgMv9%4<^a zo+X3}_>uOKHPKB3J)#8d@PGYOWGNg-SROWF@mux3%kaDztQ7rxL1^$PG%<^ z-4!>!xUXIYE`(D*E7e$%s3#g?*r92ItM#g^>{3H2`d-ok-N1~!np@8{h2tvXm+~w6 zGFld4Ce9Ot!sG>%Xo$_97k%6!1j!bC%m&MVSVyB3r z6Oj3FFQ5v7%1CTyp^~RlDwMyaPpefcV_rbZ#ov2X{wc&6_u^=lDn%JRPf*H_$wm+% zd5S0ut{^yHK|)>1ua?WmQrc}NVe9+1k7qg-s!MTFk{mGG1P0U(8JH0U~8V?-ZNBUA6{91ESn>80$uopZu9xPb$+! zjA-qlFC?78vqlE)civgG7L$&138eVU5lM@9BVG`1mtWwaslL=uFD`bK?z*(jMI>Q< zaO+swTBGD@8WY>%`sBACaiXA4J)ND~x^H}j_EsOdKJ|K%1`5Whtm$(&c0oE2+o{RdF#c{*;&`#sK7vpciwShM2O)m`+gN@B4%$>hn{0)fpv( ze{1JJQm&O-%80^qjNzz#?BFrP0`FirI3K0;iS|CTyQ%&St#dD2DQq#iDY#dB^H@*u z#-IN#?W3^yz2QpJ`!RSfVYhUm)xEz)Ls;bW)p``iQsNcu>bp`#X~sWuS!JRrCZsa! z$)PM?Z=;saLf1LuJ|!JIlMtiUBl{`1qUn(I@-)}w%je$YnuRoG&7ya6NpHz12-#J^ z-F2O&sEyMyq2`uAg37w!U*+>sRMb_v!h;9mgR!Hc_PH&Oc`cX~efnsqXHux;+%#?U z`(`a)a$u49VZz|C4P){g^VaeKmlkro*P-S=@H$ezoU{Z;xBhlU@dRzrHtW^3!_tOG z?lt)buF>O{(ybZ2Cg0K*F=0Vi2*6lada0u>Rr@kLgY)BXvV#;mT9*E&1!s zyqXIy1F)+(dZn|l-AH8o{;R%ikjEpl*1PTaUDk$p(6{4`6vEt`2|?}sV^3}@MPWVs zAK%3DuW#B&_s8e;Wv6;M@2~Fe@GcjcQ1bYW4c6S3x+*dioS*nhF5{yBXKnM48t!$k zS7sXJ58kgki^lds&=|!|$m~fgZ!X3e&AWaT47}d6ICHp-P zw<67j77BVfwDYC@`hd0@k3OEAn;q}rt5fC|N4KKxUz;Owt|fZ8<|Gy-bdHUxbuGSf zW9eC*_hzeLzuqTxM(V*QyZs|Ix4mx0>85*EpiSy8Un)d9OXjCb%#~TGvC$&im?Hmw z+oXM_-6w&;YVL0jO4Dn>r2u49yqmQ8rZ`wiF?5n}7OHT>9(Ls4Q!t0~CvQjaU@QSx zy%GANj3g<@zo8Csp-o*Ep+_9`(CRQXG9)xIaA1Ysx+aPiwf6s4VLUibONV2%kE2|I5g@Qmskf>{@eu(TaNBMW$m&;F>t$Kw6hBTb41{@ zbh$#tBbRQcUWux7-Jw_(aP(#F5weberoNUE>Gy&f_6m!Ia?{k);byYRM6IExC$H0X zlH5lb*SV5znMx#&-tB?Uv1*j6cL*d9jUkzY*5QUSzepdmG*&{7Is1dw!)NUSY56;;Q+gvVqX)v(k^ou2?8& z-a;Y8GS%s5jLsg__9IBWKJxB4mj<=o9~@Ci!H3)T<8d144ysuT;YY(xdxRZGx=_{U zu+?9(Wq%2Hw7Pruc09AxrwxY!xz}pB_0o5yiQ~*zRk*Lm7M2}MPgTmC0klt@(OnXk zQv->Vhta-Rj>)%Vt+*@un2)=wvwq>dfIW5lF3oHu8ifsK^2#~nO%d{XewO{TOc|>O z^jXm7nXo&k>mM2)XO;Mu;E!7dS#Z&FHp3H%a6V4(Yu# zdW=bk9iUe|+}8+S&<%8bvT@n-LGq<^F{R#De@4knbU!flZ8T!@xo&)wRGv6ZX@2Aq zIcyv-+npp4`ZVA{)lbQOc>Ogx7|+)GLshyqqQQOWOyPXSicD6)6gps1Y*zzf0r)L< zPXhHcbO}$Yv^W)L(nFK4R(*}$F1QkM-3f+6DUGos)qN9;?k+q@ehao_Dzmgho?!k6 z=|Ls@i;xe3-d{*&IdwE^!2e3N3VA2OwDOf8o(8_;@Rcyw&VNnv(48q|d(CN?X_!qT z-fO|Wmo?Hw(}2AT@P8#i-vzoe^Ru}fIsTjcZ>~)47LE_X`Ec9ej>deQEdIEy9Gi+W zyDR*}^@E4XCq0|x;GZkQ+SL-)vIUMp%~r)qwz5Rg3z=GF!2Fr5vb|$-%^$Ohogbwf zI#*R|1h25so|mr?AJT(aM~BA<{v0zbg5sX53eHuHi!5%;WMwgaU`)E-r;RYuj)Q=> zX);XZ?O*Pv663+06Pqp!TNSpd+6?=AwDl_2s#0pByME~9R;Ofi>mi%%K5wBTWpa~n zERwVd+R;4^;Qs&b}|T*83d33M|h+S14A{IcV(;nu1O*GTJi;5x#J-4#=@I zwb9ah{7f9g(RlGu=IckFQyn2`P0@5BbH>(pdF8}-F(agQ{J%^UBuoxtE6_S>^ywC;+*3hqroJX*6hZNvsEZu z|53;lzsByp@wGc5+$fE*IA3aCNls>f0Bkow2J)(5j9hNU^670`YB|Pr1~)4rJq25S z)E-ok-0OVp*KM`M*j-wYDi=eMc^X&vMvlcat2s6+d>VvIeO*y&7y#SVbk^(Ym8-8Y z7zNK6?8?R233e?}qt*xh<=1z2vOh3dK3*y>2jq;N$zpQ!$jX+gEs8QkC5rhlf`am` z}&ipvj^4PYkU9QhWGPe63*yWk9*2DaU2O1EYUWA_!FGJSB~c9 z+jF%JQZn~5Lnr{zsN-uN?0>ey!tSn){u;TADATh=)UXe%KSMLKS21o(g&&@;dmnP) zI#J*9l*)5IYvRA0NHVoN+ZyDT%WqTk(R(XJ`1r?=z}U>N?%8qOhzY6c^*s;i*cQA$ zVcg*S-9-Ao>`26y9Z}_AEtbk*^)30Zegnz$`PAKTdJ(d}{glbN*eQpGe9XDL$9~a< ze(*PA?`uDSl3s;jw5H-C&`57ZHN}_hG9O$`21K~rADt_o#cXr%&3UB4UwAq^0Ka4e zE*~&jJGd0aHKBV}4hX0%8j88*anEEP%PQ|n-9BDcd|*wjO-;?1+B~A)K1Q&S*&=?` z%JsK5;Y}aUfrIw6=B07Yv?QBysm|9OSEh+xqkURc!^-8d7@8ebE7cea?%{Y&!5x4L z4ME~BrGo}9<J?+SRBf|NoE$JF+V1pUd%-v2%{cMMu-StBLpC$ z^%GD~BlZxc1C&fHeK<_eAwMSl-@y72+~HY%OzFRe68>}Q7sx*`_a-HY^?yS!9fEw7 z{!ipKj7bpJXdUbSk@l8hZLRy(cY)$sptu%ycXxMpcXx;4?(S9`3dM?3w79z#_uyXO z4Ro!&*WTy-a-Q?0BXiCqP3YBxd)#CE{<{@qsyuh!$%-d~e9v4bPBY#arRvm-TDeqe z*mA-#Ban_@Gl7FaoRr{W`vG`@b z4zct3;-<>gRqg-**m8r}Hz#8_LVDdEZApjSM^Isih?NYGOsqQ|38La zv|tARub~$%kQ_wQIUUfStC-~oF@alD8}3EUAix{H*x6r?fs%ub<7QTAh!vAbZc=F) zGGcH6W_ICN1)A8iP0t~X@JDj6N!`q_m?<3Xy&F(Hl0Q)W`|$`ddyqd@(;-s>w&HH{ z6WzYScnGbiJG)_hw@WLiFf&Ko7N6OwHq=`Bwarw}bMq}YW=J_B4S9lVfOsoSF&IwR|l zWJ>Bx_ODLC?}A39SEnFIF!RkRfCoASuAZ)Y4zEd;leZ*Gbn_Q$tgrtz34ZvB>iY%c z{B06&yqN?c|27GPUrhr13B-NKIWP_+X`o3!)-$C#SObUoRV2hq_DjkhN*T~3(EQZU z&v;!k8?x{pb-l-NkQOF^lfs4yY~aGRE$Zd0hp~i{r#*W~JWgrTZTtemx zmLgfsK_`#oR>-HNkLa}zr18~6jNrf78uqi#jDESB>3nXc56?5t1s$Eu2eBliV!g$q zGJvI~Ccj28zSwFOTKKC-daI`Wu!YWK^0Pmsv-}{MmihSa3!uk7D6P<(yA(ah0<n*uH$>H{g5tUd2-NaRrc3qCO&S> zeY;b5#u(YyP8c3l1}v#9p(Kk&`y?l~StU!tq|Bv7B#=>)us{k1#CZt}-1+ow{`%ei z+q5&OyMUkB3BOZjEQUMw_q9gOntOJG(eC4~MCP&|I3ICQSk2|crLi9Pe@x+6enk@) zU{dok8F=4?h|z_TeG0I45z0v(bK_j^I`=jXC-jEG?L0ErpPR$i8<90PFRJ$N5CPDkPx{2m$ZsH zx~V2W4(es@_>$zGjyxJ;EL3byfVh6f)rVYolXdW#^*;aheYhIG)5ymQJ2`14IHKv3 zJh~6tqDwzUR~NU3>sWp*Up}QU_W&f1af9_%sYq2V)3qVQEa zU+xka=RUg8uE#v%^(UqH(oTQb!#nx2E+?8$@=OA*%L8(koW@1V{;bQFe_1-MTDx0@ z_av}Ar4l&&8gJ|HyH=Tjm@%_s&%QJ*=Wpw$x28=zaC$obMQRR>g4jpau%u%nRwdBM zmqBsAm7Pt_<~>n3=^|3vy%|9zJ|l0CKlhj(kW720+}3KJTc*ka2q#kpf4n}c%(HFF z$&+{Yc3(elZHaoWho|=>l3L664gNInCAnc_R&#LknTLXIuHJ<@GgIjMa`-zcEz_Zs zy*|c0e4D})4!z%w!_M~I0?j$37T7Kid2#jPn#42-u1*Oz4IqI1?d>m%(?vS6L>_;t zKh$EHA-s=VmkREtb>FwoOy#`7sG2ACpCvo-oWicL)pCpa{E|xm(IaZe7tA3~zt2~- zo=Q;bnmFiUYWi`5^bU`57o>DU*z((y?yJ6zcCxLBFP0Yh9mIaUg1!-@-C`rJzP+-Y zIq8jIYBsIEFt9XnOOci)yjY39O=&pa9BwL!2d~up?h_v;m3*F^v~ZNksk*rZGp=U5 z!AxtcR(e+jvt;Fv;#Opm1Ta}Z|%Pk)KArLi6}Pzay1 zfz|p-pScv0u{*omW7fgUR$=3u4J$lKOs)M0<+YE>5yf*r*Q09P8M~rUhnX|WoXfUl z#!9xwT4E*_Bjpfw19!hZX%*~be4(_%4NXSn3|V5iXGtT%rG~P_MiL}Zfd#Q-3Fiu3 z?F$Sir0^_BQA@*J*^}9Me;$Tp@^PrjN54kZs!%)HE*nsaFZm=Vk`s}S4%beQU4P`9 zz!mvsQBEA;Tvf^9-UJ|z4C=W3sV*Oa(Gj=$8$bI!3%H$R>l9qp1HM=m zr3vmOGagk6?M-kv=Rk6<`=P!AM??i*#WaG20W3osqks=bQ<6i$1O(&}ya^pH{?^#V zT2!ooH{6yH4mN%wHmtGm$gpT{mKrt`O%R$OY<@CWeqR`dXfORIh?Aow&Ib=tyL!5% zd4-cq*hfvB#SY1?eodi^NWPN@!trLN?+tc5Rf*q}DO8aD_F*FLR>SUALkDz_TR(J$ z68WMSy<7dz83nR(yN{UKQaqQ2pxE+xEZ=D#hjSbx8|Z_44uFgZ07QYj2fsaeUmG5< zbNT6?FWM^am!1QvvspaGYG@XeKI@_OZKwHk<~0M?x-YKaA(IJf6>EpU6by+opdC1yAhdr`c!$c3 zgDU+6d^LSlpu}#9tl0EL6=mitk&b0;wYJ#{D{%XD7;-B9m}n7lqnY0wK)nOHfy=rv zTTTLn*!>n9d=*_;_gjZp$bh*oh#7DUt@y+QD>1v2I8Ob znNO%gc<+n9kCpP34BB;j5#$9zsjpo8Mt*ftKm_=Kvcdw|!c`hsalq(RUIf@${i()(kapN~qwSBpE* z19L4*?{_*%{YCkU+&FHHI+{pMB{Qb=K%M>hrbVBN=7Z|tWq?k&q_@gJwokf7 z7>$Fa1Fmvwddqe)6_gtKT^$v#OVCe?@++lpOHdb+wE^G~v}X3)xtHl9Rn9$d2`cqx z3EH0HVP2+mo}GeQVB$ZMzEbDygChNsRSJfp=E5D>c2`CiB|u=n4<=jLK^i;pw*CyO zp|f$ff`y;nEbhn;vePfM|N7p*Ll93<0s*J!T=i0vBY%pvmebM>Tx>2Y|dTZS@-2JO{ z14HrOt()2(PIX`h?JCNIX#1>d&2ydv7jdk4eK&|T3{0k?R!~7YNLR29W@vS0KMWtX z$CJ0vB1@ddl4!@2)O@RIq!$n@yjNZOD3K@FnGvGv^m~uuJ1-=+=b?ust=REaU^s9` zZ(&K~$Fny!BjxE-`6kx#Cv+R2P6WIr@aiF7Wi7;fJo}muA>(GoR9}w9s688@bMkgA zp=q_{B-X3$KWIkdWj)@1Xh!PHyQDZ;my`T6srF|w^V48Ly(9mHGNQ0B1g=8SIDo5A z@&8_h?)_PXI{bcHg_^o8S$SxBq;BSObw@XXN^3A6~!)$p6th->aSjFIskS9 zW8^bmyMdxMz-}Oo!eQc`(WhQuU{AJbD#vPY3THX0&+oAazIT^zYZm8G@#kr= zsI=DERRN*NlGWF-n86xzI@AvawUr+QkF58Zu^;BKNqz`-$mRGdT4IcAp zOiO6Kt{Y~yj1IlT=u&AG%<-zwprB(X(g5?~`zeZr2?bh|=;wVteN|F*7!WN8Rg>b8 zm`5f&QM)cgyxOfCl6#1)zd}fcUyDl#*wj#PO;#p z0yBSy^%)`$q;AhGpBP|N%16- z!txPn?ZV6#xgMoPzPfkAEc5X+fyV?~wI*DnDa}l4QPzuNbtXj+QZ|Ar_r;X8CiEYr z8<3UaX_nE0o9hk`P$7Z^4CFEX$O5548kf)`OYms>{`*t`1Gm|;!)8TZ|hQJ~q(_Bod zC{K?vQtxK!m} z?QqZO8Hx9H#cYfn=PBlH$55(^?B<)GUJFAMM(hrckFHmIZE_{WWy6>!Uu}(h>Lkj{ zCXF&Nk~g``tn?4La;Z2RiZytCxFocSYw_o4e&u(J!y$$o6mYEm)Vu{B((vz^(DDDa zCL|QULksb57}2PQgFOp?;J2dQORw$)V;l5N_K=;6&2#&?=#VJQla!xxkz5U%BW9<7 zM|+7|p)Hwf)?m%&1xTJGYLF9vb3JX4tIzC4Aa^8-{Ey!RU2RkP>NtPH`(7D-_Yh8Q zX!1mv2=#oBCngqb7K_d|2;fWpu6elTkgvkCHvt&yJr178;deoLvPbd}&}~;WjK@R5 z6^5J?aDJ1^=zYLQrxsy#N17Q*y6Y+&diJ|be1uK9f$sST;|&3CB>cDSY@m$eZmzd* zIExB;i6Ls!jfiaqz#D+ue0Gcz&W&mM^(~8BZ%_}v>ZM-B#Kl(6PT_GoGNOZCyU`fr z@jjHH5lO3ucPHO;{w8eADX3_@*X3?5O;nf6oMi2Xbc64{8<-A{=lob>o2DLUO$vX= z3aE_Sg(As&^G43}+DwDhZ&g%Vqz#LQi4X?bsCP5B*3a-8{5g{3Gnb=8heBG_7FaD` z(6h97858(HD@gmxB=$F@XWMJU*I_eJk8?lVk+Y)~jGE>fo2`=Z=c z9<8quq4VQUHo_#bVr2xPrH&f+ml$iDHNip#qBRLNRD)oZywzM;vUT38-C?cYK1F~H zr5X<(RPpAKII>~yk9mIojK9cV>#uLcTa#=}Fara8kVc7Wt^ZfS{KUl{nv$$Fnno=w z>LRR2uMj25rlBh9Uu0}QA%GUir(M_8TZj(ZyZ%+(@;|AHl@Ie29#BURFMp~w;w%$% zQU{(h3Uz)|@tX!SesM}yLNI>L)kLwi-~_0;AH>YDs^G;hR(B_&;e@dgL0q~2RirpL0L*`ZJ`4sQY zcbw(au-2v|r+g^SF0r?1*ABG0>}lA((U-??rh~)kj8#v{>P%ffPE}M#4KoF7eIHzm z-;3|$9iHx_4*G-S8Bv&IFbKSPzw;#@F-Uqef7h}VI-Q;yuCqyOJsX_o#r@2sFow*K zu1~C;yfE`89PR3Z^HI-1`Ve2>a~|Km+ylqae#@S%&j3B0&m+9)!w=P~6EL+OAML@l zIkM25&FEwyToRCpNuU~c!L%fxf&V;IGZ`+HCdIK8^83cDH90Bp+|_aL?<)o%y8lYO zB*ROOTsfG@@tZ!hITnvoD|C3{IJ)j_W~rq|CK6o`KSGcQhI{1;?{3ngLk_JWnY2#t zeWmbwM9GM#C3u|YuTM|NFC|xbEKs(=q76j3@S5YvS?re*-}`4p$CmhK zk>^QHZ|VHEe&l*syc|~Sk5-JY*>eWm%pfpahZqp0!}XuULb3@+?Yon&mCXVyBQ4<{ zo+hOS@lTIymNx?NmJ5#fKcdZR2yBMJz&7|!pjJ9}m2B<>zmthBcaZZgpj@a9HG8Gz zypQPK>UoS=pEbEEv2mRoR=;?-T1Gi1wyF46kLDXhRpHV~*vcvbmF6Ozk}V(P4f~67 zX6?`Wgw*B2z`zvBBFTs^RXPL~@&7PS3{L)Ko@}}OKbR-p|1?h!zWzU$CjwSG!nsLY zTpbJ-k$7eEvAM1B#OntHvW$>FETF-f!&qgpH$0eq@cSj)M7(Dg32GhA=T^@I2VF_@t>NE0&8 zFDsb0Z_o7llnrEFU&Jg=@X8dM`-v%-X)@4cB8!WpB4{ak9XtAJxnOSzQ$7rbne;Lm zaxfV-53q{A-NyVDr7lWMl;T92vbrc`Nth)^k~m9HYBs}kxJY>IO#l&*rAt|YZQ9C8VYIg&eT%djdTChM&$0zw%(?|G%h@7k$%gEX%@6Fv z{kvhUEnhAgR-3PhY~LBISZ3?0@Q{91u64ykI&xHP6?p0jjS%i4IfZfb;us<@e3xL@ z8;cqL>nJpBI5MrGD6Dy@j{|ryH0`hZHmgN6cdnp1dHM=|DqR$JUVSMTb(lLn_N2L0 zclU_EH$SoT)ZXOHHAHY6>^`kvm#q-xt#*;UQYcWpZD@Mr1gpog;COP(u1f#GPfw4J zk+zwxFI0*|>HMkhbgbS{@%NWIiSxuwvcB9SRKIMKMYB)6yBzx?IR)C!_xBU4*7B@4M5udG2CV_qkV@x2YZmXkJrdKtTlfH~--8w_?zR zrF2e)S(P{!3LYOgkZ~|*@^l~_tQiQB9YA6Xf+G|H$_goFOI^WJe&PlUMjFbl)Cua> zWCPwb#NEJq{a0E3blF_D^qkLx@6$WXbS@FoH@dkG4A`{k~}lO z*?H(nepnQ>?11letG>_R3@)aS!C)Qb&J4)E?~{jeU<%ro)pN)~icO|dxa0tmj6u3( zKn*Csp(?Iv%&0&FKO+$U&k25>04H!Zt39_KqW?fYhyxlSYl3iO!WjDbQyvsKXvF7D zkk19c5?(GyU$e4dlWk#90y|B2xcz1$N<_n93YlO}xc#RrC^F!w0@$qmv5}-?c88eA z&!Tw_>l5l#Q?T&Npwaw96Hv^%&{*s24@N#iXUAh!>#=fpktJB*oSE$*rZfMf;8V>~ z%?|xd!8a^?E>)5$7lkd~7VtLh|pj)5x#Yy&jd+q+}LRF1)mLm%-ZY2(&RJW~lp!#)@WVIU@9 zRB!d}k4dNS9hYZYtcop_$hGTj*E?tL>(6wmFnx9K`pv#5evzpzIl(Y)nepR1po*l5 zh1P%gTKk#$p~~IWT~5vE7aB`?SXf7R6**19jo{?COJ-Xk@^(#n^mIrB!+RSj4O_;e_x~yc~ zR0a-Ra+bRKI^+}tmNy`C?jy}7uoEORe^Ycu=7i1;lJ$(1bu?R>W8!Cna#nf#(CX6O z=NJ|*(q z@j*aFh;~D=XqGdl*;j?+lU3c-F&Mi+SqZd6YElj9T6c@urg+YdFO%%9bQ;HFan1>S z#F2krOfzN}+>9cPL~-63Ys8-T=nSqSp!Sz|)hMMw0hc!;#*lIS}MF2FQo0yns#Ae5i(ZaySr6>dc=O1P!O9GQrvpv!JQtYEstPK}1>BpwmXr(Q#d ze?>&xFNJiDF_r9dgc()Fj=9o?aL8~d*;9;l^gFYY8`1BQN5hoxrID#28l6I*)LyNf z$3DQDa(=i7Onj_Om5a*v-dlwTfXKkKa{Np&pdd!BTGSa#NF~UbPMj|L%hI-!Z!X9#UM|UK4641F#~ z_j}w{rZNc5keKSkrjO%`E2j4v9sDJQ?HGEMQc##na$(hd}hGHZ9w|?HWY&+Duf7Hkw9z#ViwwG|9YSmMbol zLG5#rPaY=btTaPu55V_QVy8^n{=i3)ZVvoMHM)19dlz5XBYansf^ar^CBbN$*-4bq zkBv_lI%2)?^)o2wN!B4#yNm2C=9*_l4_bc_rt`f!$^(fU!3X|+ zAfTR3R#|2C%-hH02dG@QReFp}n{-*nzH1S{X7eoD&WUDvH1oQ;ywePv?tYMW z5?!n~OnOq6QRMtF41Dn3>weUiB3Q)1e3#cZGYi)P@BXNc`)T;o{y&2scH~JE0==!x zY@rzyC!~f67F@m3TH(^zSSy&4Vu6VkTm)3>-!zPVdNdk2S{$j~MN$6H#BET$j_tmZ z`S{!efTN;Q^IOCVf$*}fCHtG*3dz=JJA-ZE@l%my?)^>EHy6R2rn(6^JRH0W;`JED zSoJ{euAy%iZCV_g!Nl)fvqZRndtzB?Q`<6C`yp)-mG-}6Phpi7)HgnVM8E(|{toRP zRc@%9%p0VjGSWGGQA(i7ZjXV6fLM&>BWLb*=H=BoM4kb#fk_W&_%> zq3q8NKlNrL48_)s+o^&Osr@s_wF177x#(Ru&OnoulpN!rA z*}gBf%fobv`ScX^>}qBguZAcpY``VAL4Mq$yui_57y@Th63ThSC*}GuWZv3*7M{(( ztcN&SJ*ULz7j4KfhkC=#{6b5Mw1S!O(tcV2`Et{1K1DC{>KMyB+uh|s>a9l1Rwswt zdUujU)VBh6U{4MvkD^YP#9-_lttc=<+}3>br!q)M3SYkzOoDER$0w!`4I?5^&$sih z2o-oGVP;jm{{3xWR+2Ntq#pQ76{~dYkeE@Ou0yw=jqGHkF~>yj=aS^*oGLF}k%m^Y z!-wyU#qe#zZf(;Yp;wC2B#8>{2WrA z`Gv^CZ@fvDP;gGhV0g!!oFPCwLqq(2Eo7wi`<)B$gW25Yo%zYw=hI+w zA0EJc_7BD@`aq<1p)EdZXWQP?Q;=){v$qd&rpPf3*sOP#An)q9s8Dwte~G&8IGTRD z=l%x%ZUK%pa8#dZAxp)2%oYkf?-F|Agq^>Y>Ehl;-;_zK5D_e#9o?vw%t5UNSs;|E zqgrlqc>WUhbc-UpVeiys-Zm0?q}B4>qt0NDh*5g_X>=J*N*T51)1!`LkBAUqIZjg< zbtL6jRj7h(lH!H(wPk^qo8OTEvH4u~HT@aaUAFW+t~pvppK_i?0Zeg%Rff!wOI0Q= zlun_B8|l)9+Zk8-1=j>Mb7UC~#;9vBRHKT*PfIVMs~Wb{W1Gw>awhI9oZu!tyysyV zytzOhltP#j`5#Bm=qEplw~ho$STUg+L%5cIgRXot@S7$S2}c?Em3IIg4q`3$bNYj| zl&X`SgYmM1!u;VMExkC#dQ*P{I&6D|>8O67`E*Hrpc%iu3`cY}=epi?OLtqvga7UY<0%(KKnUt~2DZ6pwlpXO) z%WbBZRsttvJ!X1f|CC(&p=4g@q>9Ots%5I7ZECyS7M|EU2v?5{*&sN3Y{Z>P_(E%g!H)1y2c**k{aSQ%epHd^avFlkBCyw_uskU)sVRuwJ#(qIT;W zoG)1qtxZ6CX0M~~8WeKr8VQZp2yH3}*0z$-$XIZg%uB=3WNsAMNJmdYJ$%ski(p<2 zJDK?_o$AW3hu%^Tx2Bx)=+r1X=TQv?%N&KvG?>C&Jv}9bV10xm1VgG`tM)wZb^L+U z0{1I`NDHWs(K}bQLg1X2M?M$sb{FNTsN%wPMbWnG4%2~UQml|b;#U3MHmXa$(=Fno zF)SuO?mJH+#>?ZS#UQS;YyAG6#HDYwupi6rbrO<%Z(Z@*zR`SA(5Pso_%=3+mA}Sn zf0448W8wS4?{zD7Ic!nY4p6KooZnoCuk}}664bRa^)#iL&OEkjB2H!Nt6?i46A+%++|T980m&=w~0@w5@^BGaW42{?rnS1lVH z&-x^2J{OG)O7Kop{$M7JycKH$n8#m z+c^|}R1|RGz5P{ZX6ln$)dzg$No+UH5Dm|q6_-x8gQ?InrFJzb210hZo=K5jM~JioXZMnRope5Jyx0!5>gI zC5BPS7`pFDx_MsV&5m(9A_fG}n_3lK$kdr-%wr4q9y%@; zA32pL?#~^MC4IiL`{xiuGD6 zoClc4Qtg2wKKbt}cr5FY86WtHzam;6Wy1EK{B5;+4qZavfPi zX#NT;a6p{-zXrsO{BuAYD`}85xDJnZaw?l5na<;CGL9aH3ZFEVh%_1Ij$OK9O!*MW zBWI^REeDR9=w=%VTb`Z^-F_POZ#^LfTU>BaR+h=rY;!BMxvXH{Q7*ig07wHhx=~d6TQVkBhu5%4fX21m8W;|o%t+$FYe=^ zK<8J1GrxYt$KnuQ`0Yio(_LC_RZ7WR43>VS>&Df(YGQ}-hrK*H^xLzb$h4G?8Jy$;K*?w27Or=)dAlj^2*-4RNS){oWO=)|>I_1~Tx&1%Lxum_3 zW`Zl2K@V%Y>p~DytgFp)0fX;Pnwe|fuc5<~=#YcYBiXB#v~54{>UO=dbCZ=b)FgI9 z4}UK>((v-R!ct1))S*XBNN|}49YGh}TfcjAcpO#9`Vgaw$@&pwB#5w2IbnGFkn9YgB6$Xes`HVwwo6(!k6&Vr7PVBJHY@fis!Uz0=*a7P8mAJYy#j`t` z9o_`=<~P!wOoOMII)(#_lXIY0_V&80KDOO*5;Cwb5>& z^%Xs=H$xgHCtTmE0xx%yu?rpT>w+#L9V1N3=LVe%(j4GX}FYT_kN4@}G7Qm`-z@IN?8? zrU@0%gTp167Do>{MAH|>M>f7Xzij%l6^w*PCf{)-v)`)fRX8-sb~JradNZh&5tyBH zv!WfDPYXg5ug&f7#HOnGj#B?%rSBU@z%9^Dtac=GDIsQa8&c6imgnl;{jiU29>+HYh1 z!A*VJAGmWd%cHDu6k~A2!PmY7qANC-F8%i(` z*wO1^gL2_-qgg>T>S^T?q|4i^E_T&H=9Xb7amszp|6m&3{C8g`<*l!yLnCAH*4N2+ z>+9?f%1lT&gq#nFbN0OE)@{+y9Kyz@=Fm9kR21(jSIE1~&M>_pa=Xl`H|zeht8?_f zO`b4q{&(^u=zp6$$$U4~oK>90@8DKUQJlw~ZO^!Vj@<+x^)LJ-)-o0y?=uP&m5eOt|AH)<7dkjFuV?0b`O zu88%1g_UfJlnvbg{Hoa2Z~dG|bSJCt|0niCamm5}o?FkUq`x;Ztb=}{NNXwJ2~1B2 zy`R{y75b~Ls$^ef$8p4&8Zb?wdouF)0PO5M=*XmK#fQ0y=OQbmN zLw}<8RatHZqGxTe4t%CwgCrJ$IeIm-b+9e_uR#*0KS2^sS)E@Z=w{`BU=hiRTO?Qw zWLOmE1~YEBhmI=EQ3)G8I*fWG%YR{K<)eYv*=K&gJU;cuEq?l2i|I*T@bF3szCQiu|JR zm=XcvMcvj{hdZwLespH)TSAKm%<{>Bwo)|Z28+%0$5W%?=oF`E(aUchdM3I)x~Svh z(=%qc*Xcyp5)gPrVT!^OEv2Y#e4ahoxI!1{rEacabVPy}A3(WH%a1_ug2grz+?kJf z(4(L@h$oyKk|cOslAt|!<)alyYp^OID!MEm2Si)y_Q2xASre>@fsMz6=pcJSZ78YKKACnN<^CQR~7Ot1ykL`7}xk$UywYM^IuISC!sl^frXJc`ZXN6 z-4N;Ap75C|8uGjw7l-{y}ttNWnUi4H$D@mcR z!3tXjn!*7hh3pFKk2j%1MXifb$Bh0xj*9v}#!=}ZZ}-jw$W46-5MWQ7!+EUU!a4)SKdK~3b&h?+M+WzVyNL*e$yTG{M3pPYv{b*@DXIYda9fwru15ThRa8Hv3az5DN0qWza^aA|PN}0ZM>&p44CQ(;rJGs9^|5OP;m`Q3 zfpei6=VPu^PHq>P8(343{GQ2;eS2qiP8{4Ag!OU+H#45{<0+1}L%#P?KRf<$SuC^= zcyGG7*I`kBG4g9*{6*z8{^DqW%ILrK*7%`9P`3zVwx@@(1^-@Oksv2yq>0U(%*>nTAN%(&Ak{)Y|1;GWjA8RM$8Y(T&+g<1ulW|Df9G3_9;!TTJgF_R>wA;JPm*erchNM$&ENxTF2BOy8K1BDws#M|AY3NA%TRt@17^Y!2)jvh$%} zwq{w${>&jb=f~^_S&@?00c3~`lTHD6D!GX**Q^T(0LPxE* z)D+4*kififw02YqKLcTD6le)nx(YkPd&1hP6++`Sk^&&HJ(~3-p>fbM3KibBW^x98 zxc|a*hsg0aTkNi@+QUIHGFs6B zc@5nHyW%uJlxNx&t_|+};NLl!_&d(*Oaf$;X&BfiyAqlcwcob?#@8lJ#b8ziw%oSq zN=~-VVquRLdQGV!0?Y5z;EBF=p;Ds?I>6D8ZGS9y_<3b)sl^4H_5poAt zc}ijVRefh#)*r^Styx}Jr?29m!wBGRU%vw`7D!{M)^?UXJ#At>d5TOE_XU+0YDDbU zEi#Lk@X9zS&=%XK%Ey*a1bO3wk!=2Goy8VcnR(yeIOZR2Ux#g_Q2BNMq*Q{B{+XO}bO#>0~FBk;}+o=CODd?JC9Gjd-d>6ymJ2?2=Q765-Bi&O2d0h>xNBn?1iPS;tz^f1kQzc4+w4N89i zOf%!6=qFLljwJTMO+Y z#3{1eHVE>YS;`yOyL-m($xpbAP35X-ZS8kS-z4Of^WncTYmLyB*~%=q5{O9hXP2>$ zXY!R42)KQbE-r3pkRA?6uU^B#O~Re^T)JtBmh|qDQ8NzZ8aCtDlQl?br)y&Sl+8M-SMV)PY@250zIq4|?m0qM~;fOaTk$s@zM^e34hK!KH5bfu{d z=^c?RXf=C#ME6N!B-z;|shc5}#*O5$~Q zxNQh0MJZ%GrOC|%IZ%e`xbnU8?KrnJc1P&UeD75+kpUxgItv--uppbCc9f9xP$g-&vsVHJZRX%nh>s(x)@KZc9jcq*RSvJ=$?LJJ!aN57w3S zL%=c)>&qIFqS{y_g}6?qy)oLO4^G)?df)Sglh#rLvFeNr1|FADB7qFvCXHk}bp(`Z4d@d+PE1k<8IPmtKvSq8X)m4wKBlDyd5ZS2cL! z>MyZ<)kyT0w)+SGI!6Vmbv4i7N6V0^y|gZUT6hR4i|#0h;os);5WYKZzkr(nDN?*{ zb5hSe$XOmY6sH!A&>zNa9cWW0bqiDF=7d)B0T-nJdO%OD1$wYHi;Yg6W|e=O&;a2! zk|PvHkhW`Z7FVQiW!hkDmtI<1E_i2~J$?a#LaLZTXg<-IHnMwzW5$n)pWn-bei5D_ zxkGWk#{;D^0v*r3mpwOrwp=MOlFxeLjb84!V_(&GNaNXPH8@vsiH4ljj&o!H@FSP${&zcV{Cjn}bI8%Avcj)=+%SKdd2DY?Y_=zX&(8ng>9_4b?0bkZ@D?J^q<#PC@d& z2{)I{y4-SmV_yz$U0oWHucat+78v%hc0k!t^NU89_WAKy&R51R z_vrc)x}jv6QzNLYW{&Wq&q#KLMf`Yikif7|W-zxK=_5CohZ7z{j4PA)`^_Dw^TWSD zOK(yY>kf!Ad4JB|R-dzk^#)-2>+%s%4p$(!UZ8u84#HxLf_n|FHRN)aD^R{;Ew45$ z3O%-;9^K7hNm|dGw=U0qqp?;oWw~V;7`=RI_+AiJJ=d}F&h+$Rf6&9Xykyjn+{P>3 z@ebN|iHVOP9K-T%Uc#*)IJhEEz`)wMxTJtlsHgLNxt-qvs zgJxW2&9GTN1=bZyLxa}+Wnjp$i=Ju(rjB#C5^28$lh188FX5bME7i{X(-tI;fZRl4 z#g%Zs9aY=TPmkejkNnfVOFoFD=Pv~_t@ys^g*e=zwuU4_SZSYBA*&W}-cMV`kTD^3 zqi6YZE7N@ZatL<~`Q;r2KTI6xm&OPYTK*_5tLYG=X%NHD7z7Yue~AW&;Yzv|RPQ?m zemEqsKT-$L+r;D@3LyUX-9QZIK4YYsc7gokZeYFCfoTK?3;?*}S(Gxo-^(b|#&fzV z-tFm1`BHMS*%7*cZBoUN-E1;|=ua`D<+qXK#eF~F_m(Huh4^Ntj!KTEmvmNzL(CEj z#(CeyjxSa46nS+3aZSR7?Om07T4i^=2c{XR%k-)gDMJ+Wst~ULmsgI9F0|24Y&d%{ zGBUE!9NH5{tZ$j^>Jq7z1{_n2SBvZEvE59W?IfXWUQ0sSgTbP=Y7f_GLY3=H57acz z72Ga%&L(c;@vRu8xqKa76)#DKXl#3ZALg0@&!l{wP`n0*&q$NpC_85x?r;0CDX<%^ z=$shsDTY>*0 z*^mHNvj<@qw`B|$;NC5+Lpq8O11WSOK4;>*<-eIIuV@qw14lQAsjp+(zwWxW*uB_4 z{}Zf&4g{+>K4VchcKR^^hNc%oK(g>Tvl6Kw$NZguUFkRB?y>j94s~+dwG!2$L*F&B z?SiNXU}fPD-;@^1I#HKRs@|@Sol{z?96W4Y2=V};RV!j`0<(aVe^6zL ztyqbwfuqgs^oup$Mw{ccYbd;uRr6KhrWiHzIDlkTb}tzPG9^hSrW7muO;!~Y1VU4YaF7t7 zAXQ)3k$C@$tSXz6vX@wLeJM#n;-N{ySPN3Q1gBcG0RD%oq0-o7x$Xh{|5WAgzWp@t z2vU0gPWi(3Z&b?QV>3)}`rxAqE!Ota56TPQzhJ7g+fOSRWN5u_fE4~w$UGn*MQ%gs zj`_%2a_tSKx>l`eT+6I3vi6p#0p{Jq+j6@g^jP8(Yly#fo+(rGN zgHkj>o;V-s-ZaBof*d&6jqX>b%h{@v;D@CZ9-Ef5vdR*gBlW5}8j3d2Vq|@=g?Fr< zN5iFyxQ2s!VvH#Vl?6NE=+Y&Bpr3(zJHdAtQzFK&>WnC}Vp*gQT_UGTx<~(irJZF^ z9NpHaakoHl4MBpty99>>2~M!!7Az3l3BjENcXyZImLP+>ySo$M_CRvZd%n6~-8yyu zux9V>>FMIfOs~DydLB8-$Au?|*%a1T0JtLKbfIvafA<{&DxD z`A#=a)Ajje_EenHTq~%*kvJs{)WBj(;9j2yG8=RKFvM~X;b6?|-Kvq-1`}3nt%AsI z6am7D@XAa_-UAsZM>l3j>5>=k%lJLn1THT>lgs_kkHiGJnqskI!1yh-I*45=s620> z>-;8!p!z=clXTOo9AWeNw$4-{5n6m4(0+ItrA%ii6x#n!qxv*%ox0oPA#XjI}70Fj_=ob>3&*2#`{3Sk`J- z3&L2qyo!9K_(x)GPl1w2x<9dFOBSz=Y%IOByhd*HbG8Qhdp)Q6&X!5E&RO)wlGnz|nBGV>*1gy#&=T{Lxw`t0YH z@8+N1h#bjz4d}1ONEE`lvT1};UK0uT=uZF#8K@YTSBOKz(Di(XZ-K22FzEf?K?sGc zB^Wpa{pI|NAGqd)y`7oy+uM`nN!^8J?08EleV{wi&8)@9{Y+Kjl^+W?kN69h%V<}* zV}?WiAoLr~P_`$?3Kh;JGZ?Z07dNq=R(idkH$To`>P%(yITKLt{G=SrFyS(dz^pJX zG$g2)XpqamV{0;~#{Om5Zytmj4R;j((GDQ3jDtxl>|oN0PJX{iP+gS%)K{wDl=B|n z#5Br}V-47(oLUP29W`(INQ4Q~j+*k~CMPVGOUZweu0pSb@ZI89Y@x;#^_dEqA08GN zoH^z%qC{K1AfsoDPljZ?y&)m@BypvfeIMhYpuD)!es>Yd-Ji}g z)jrEj|GnBrbN-(#N&k1A3;EIirkEQ=l_WUE82xfUj}iy9UcYL+MsxC9wiUC&0?~-x zTe}~}jwSY5yT8l-n-gDRuvtvunn7<@MDPn-F~$IGlzKcn7M`D7b(P5!VhtF1_}F=_ zZ^ntfr>E&o68==BV~vBj`|9z!B|~?(bW82bBH-M@AQZysjLrh4Orq67B$We-%w39f zB=BsW+@ftjEvN$14xOSv5Uy~uJ6^=e z`FN<_hVDV|Aq`m0V5ICDt$i;=xCblhJ4D8gkc{rP0KiXR&@o}=zpcX}9L&N({EH|- zM&_(Xwl&zS>9y3X1Mx(ec!Ev9!vWZlaTu7(eJF@WGQ|_1qF)H%e%*MoEJZdIL6>s_ zj2r0)cE#Ht&k1wicU8{i_;e$5bzHYQ0Bi0R3xF8NPqnLm3jFEn?C9j+2Rp!(H`nR z*jPEvFy9R%j2}~ww>bllm)o`Nm(yP^-Dg6OIlm%HkV0#K_Mw26MvEc>5TvbQ{=!kd z6!7}(cWmi=(ZK*C;}0t1>4NSo>3pXlU~+~P6@O`~AM*=21c0mogt|!ggZW=rBv%*n zd-fo~OFX22C@rz!m$5E)6XNCyj`~@a=T?b3SX0NdTq%R7cr{Uk!y|fg$FObVQ+pk5 z*z*&yOBL5L$oQFX@H+P2J7*Ik1*VlsTXW2a31Zfl+N#3t8%HKdJg^}SkhlBwa(|D|mY;t< z>Pvc(rz#(1vN8VWiJJUiIW%&;cyjTgWq&>LsM2V%_7BR4Md>E5N$#;-_{_IGu$Jo0W0jWWVaWug=0Pw#JzGcJcQ zok-jeHO$@s8T42{K9g+2=6w5Qk`WUK{=g+WrRI%Gu_FJ1d7OZyK+oEg&ad)ZcKxWm zZ*MB1-+*iclT|JoDnQ8`G52M1T>5R|rfwvNj$eMU;AB83dVN;$QWr27(qK_9%n539 zL1g)YCyqi6_ssW88-I|9Q+Ipm7k>iJEle+{THd33@?zOExAOIOGXVGt1IZ669pHb} zKHnM)(5mbZ`}f@Aq1JuHG(LN>^ww2*hf@<=czpP%2U{{c9wkt$KIwSy^L(|!*hm*C zfh-aw^!&iFzN|6V%B3F7!=rxf-)rT+FH|Jw2QxZ!&oa|X|0CBOm&pVwj~>e)s%e43 zh;iEN6~L0Rr9*#(zk8MWKO2r{AV33QWI>0-KMVq4q2`nLCN&Wg5IAdDbHvke3w(6+ zDlPcgk@uPETk6%)u(dJi_IHTcRO&a98?uQ<)V3|NOatwTn|1bEX_FgmLW?GYLWCvO zR3*yTH&d#<2JLuid1W2QsliFJ1!Qf&QHo!Ad%l{|Frq$oH`ol;A~HqP^odKOG_4n_ zaxgi0Y5MJT)!Z1WaUAD>IsVYbdoz0`LkbA6BLh|#a>x<#Y-Zr#;^TQ}l?VI&nt!Uw zi{BMw37mD|b8YXr(yKOV{a#O=9#>}lDQbXPBiJ+}Q=2}KQWG8=C@+EUw8f(Hf1<6; zn$iv@|A>*IG^UT`7E;;Q8wu1}FofNiGw*=rkj97l?nRYWFE#{FlF)?N4AZ1j&!4$Z z+y*GvB|ln3pwgxxhs0B>`MwQe-xP_(O9eVE@D}yqz9k}_s1M-gEo$RtMhlrm>#k?J zT*nVdwEsdCo|mcipgMr@%rBm_y>J|m7XAxCq~(8Pz2dhyxE6KjUU{MkI877-53SV# zYswVARnJdCaDgr--xCNmf4M!m5CS7o2UnE^iW4gx-b1Fg=4!8E{YYxXI;z6tfAZeW zX=2sRY2;$eLDjZ0d_;ZW@svQj;fPUvGSPRwXsw^=kz&xPt<-j=z$b{KF~rxc4-!HAz3Ah$i<_3>NP8HZ z;MQ7jdmiviPorHP6(p8+M|BfO_|VFoJ4x8PVeFwB&LjBv&5I~!H{(h?1%tFTAD_tEf?3f!09xRPp$mghEN*@H!|Pn zYg$JC+em1XRfU|d&&2*-9Q+wYgUknVtXeUF<-r`&LwsnuMCzwdMd^J=MUWP)1Z8kV zKVMA1*cREEu*RqGJ%E+EIF%O}QM>YS=mIWwHEw6t)LfAU96)sfCoez2=+i?Vs-QI& z_@S>MKaPskU9+)Js~5a0_NU*4uR7PEhYQ$jGp%AvaCD=?PUW8O-*ZarqU}=R@$dS@ zp(Iy^M-La=W>9Ka{vSmOp;P~Dkpi+x-~TUA^d)2zx}l016riy5MEGm8{3EvXov-h* zcA`?w(yK*Pe3nj78A2FbEjRUhXu1h9v16l*#}e%+q+oG6nM7gG*p|7XHGbQJ`Nwsf z-j~fLf3^aAnFCVK%LoC5W}Ee6IQ5$k5(u6r2x?F1+S5Q87p;EFppr;aFaXP6ZgR}| zahEep>!80jdWn4h#asVt^|xgTRcT^@L=^X>FBSCYsZ(}P?d2k#AvbH-8bwKSpdON{$8AydPzEkY?hZRR2YW zF2r0YaaeHgzvYiUn-Pli%n*IaG8Sl(ptD5(y#y!8eD~2LlX0_Q$2u_Cl3}9nG zFbE?LK~=#=p|by{twdobb~D=LF{jQ~V^Ede@qS0xR`9&h>xpvl3rB6AXMa)KYp(*BI=~g>RNGtTPy zAHgjQ2lJt^mfNMg9OYJm2{Lqu+h zGG)9b)i8Aw8G1e7T&RX#QpDJ64q1r&+(t5hfzI;EBpt82AGStD~;gQJ~%6NP2Vzs zMH^|Y-4d87#gOshb#Q~m=&y3oP4HIFNQ-F_=1Yrx81N9!;^$KXgM zu5N7bM*(Hg+c?-ean^VK`3?(A%sq=>2RIiT)rl#n(iS>Yiv7{n&V|KF`i4rfO)*W& zEq=a$F}C{A7+VT9#+qa2$aKB~jIpd5R~e7SSnt${;;X#r6TyXPe}({bm#D2jo7M|b z*eRvGK98;im8KG>NVySlO*^i(G$W81>W%wsKn82hCdsxbTdhi$_2w)|#i%zUt~MiH#*lXpy!5v{*I5353Pzi`^f zJnA>L`MErha)F^{mp*(K>mjg#vCCB=C>#7h`(~c-TPejFB1HcGdjV1+ZSa2s4BcdC zqQ9+fV7ui3P5m&c{tg>UJ7_$0;p<1GoudZkp<+7HvPk;-&tQCbRGFM>y~!I^DP&c0 zC~!hvp>c&$0&7gDV|T=RI$@dHx%zOv9J#AraWX3T*%WtveTSgy$JCCy9DMZpirlS| z&JD}rp^?okYCc*0OlW~!{fx91bWl;X1?K?WpzqrN$uH3P`oX(huK1!wuMfmquUz(Z z{2stUfMwi7c!w*4v=THw_9w6;h)1cMyY1IFU!*27eunE}enl1SYk<}#htG5RyZ4*j z*PFsvnY@>GI(oD1%c;Dy(R8BW8ETRLaf#^=@s=~hlS7l!FpI5Qp$o0UVH zY3N(fH(t$JX&}*A`YP1$(vgR?!Tx=Jo|9c$&>>8@zJ7gQ*yyP^2ut;K>geE2lDU~u zRW}OX9|3SSC`gc+-8z87nyVXQ_J8Bu+F4pnv(s^HYKg@7#)qN0@E{7wC}zf*TSd3);sqR2G3CPI_*-cyDP*$ zD(|4tgQ4f~PodxsW}#bwtp_3m_;zqea7Ft~H|TxP|9UKw9c#ERAPMM+;Ts}9SxsL| zc2440`e?6=VW_`OW4rbtfAFZ)&(Q7b=Wa?Y9JRBAo}WPhg~>($d()nqzS9s9b>hBROU%2^gdgGy`=D=X?aDNm6805BgZNuesEO2^E)S7GRnF?%eq^pf&zU9a#TuKI0`a?|Fux8x z4m|cE=Fcq0atr+IXQbU?A_3P$%XCrE(Hv@GC35AVRs8ENov!R!LCOglK82^{&N%Xp z2%dmFG>~`239yGQ)YNIh^Nc#xHRM_1@^nlQt0^krU{rt92@z?&cb--#-8sBbj z1QG?d6fFkpOKj&nJqg;QR?|?Gw3ku|Qm_qdp3d1SOH(A`v=J&~`NCe&DrB9~v>0`M zw-f7vPB};nh5oGB3>9vH3(%&b9N)op!^_S2qRzs!AZvJpdVQp z^n2jh8+ugJ5*~D)HK-SD3>#2PWFk@+n0_!XM5LG{DYgd7gmZjb-4AR@-CIAB&cR={ zxqsB{u4gJ2P2)3sP=E1p(L#&|WL$)Skjz2}!`;V{v@e9j3{NphbhR<$StLf3@=ZqY zb5W9c#J1%H!FQY8^MZRF^RchTi(LlADvT4fHZs+A*_syY1zp{V2KiJEmjJ8Mm2g|g zka#%OW%T@@;jX;psBW_vv;|T@_-y;4YV$mzhBf`pC|_qX?;~gV@2J~EEqT36F4K{j zO7ja50~L&kFBjd(7W4l4UlSS$(n#oFOqg+(SUk_#|2A~F$}Suiv@lD~GF!pCFuN-jAY+&&$? zuy`ZOY%h>2fb30|LcB2gj<(%jmb+hKwr>c!pezB5POSaJw-xAwHGBEfHlZ*FBu)ou z-!IMC)?kp4p7|qw=B!A@geN3X?}9SV&PE!cqAIt<8Umck1gtLp-rwn-`{y_}WA$ z4Er|HsSn%7$WRFyjFizMdnC)~k*Swp&=cyU{vAHe){B-|_FurX2{U7$z(7BRK3<>e zX+mVq2;pPAA`$%@oz7e+KT>fl$abgi4U;j=F&8LyUf`Pa6-GErA|lBmJ6ePB>4xW> zQ02n(7|5M7w9GtTxu(?(pqe0US_zNfo`SJa)6Z_^llfJ7_lQ};=pzq8{bN)w8CNQPk5KjnG zE6Ox;V)A3oo|1ZSjBV`ffm&J~q?DM10f&o)ewHaUWfdIG5?f&11b^5tZx-I!NW_C` z9V&rT7fUFo4Lt|J)r$JjmAH=BhMAKjIVp+VwV|rd4A1<+fKsQxe_g6?$i&k`5D8>? z5l6JJXNGP|iAylAyFF=$R_?L7Q(1LYdNTH7IClP0^}rzQKOFR?|D}VT9!1=}?i3m; zBqj9PoB0MxLigElW}?!~i+-$bWGtM$YvLf~31>E`J;yJ^27yyAK0SpCA}m+V4L{+% z(*A-{|Gnv#7pJV9Xysyi7<^B`)gwrNa5RbRLJiZ^Zw*laM4QT{j4T)kTDHF;0*6!n z)6bk#>usAM-uh#_|9mgJ_}=@A?p7ENEeM7yR!!7ttak+N`bW~W_&VwikzqE$<8`}u zjd;F@f-O7uIT4tm{w4S{dINmYI#z2Udu#}#I<=jH)l-EG$srNrse zkUr%iagN$8dtQ;=o76vJw!cgOS~mMF<-+D*jhm^62FRCpjJDBXr#j7?I_*Q(72=h2 z^zUb23B{0TD6t)wuS|)j2KveE6=#E5w-cQ|E^2P}?!pvzY6-|J41lX0vv=I(W=FK$ z5Qz*f;~1cb&$H9N?2gENWX%q~vU3X1s3IMrMiupBbj~_l{7neQk^gR)q7B&{8O%8N zp7_K#K&g@u9x~TAH~v>**o~fSOkpDM;+1Q6N7~_R!T53m*kfr-_q5xp*s0OT6(+!g zhXUu@rg!A6SN39=+cd5Q>AR7zeQH|$rntdp!ANxr?JP>n!Xjgo0xN`ZB}BzxOC@Xt+*(-VLVdo1! zJ`pa_N-BF#PkDqY7=homywtIwHxjJJ4BUnZu`fFR4`;o!Z3Cc4VylamM)qxJ4CP~C zdD(Y9n!Y!m-)JBSe$i#M>g|TVJ`iLM7(E21bWFxZD}m*?>kij<1M;5qb9&^b08v2F zLKbDHSsg5zWCe>RS6(^%Kst0N_4S^bM>h~k?>lMtA&0lh$WhNl%K`8LWTBF3O*cZb znW;UK-Y5bckRWa~ ze=NY~iK&na;q6^zJq+R18-vV5Yt@t>_DUnm%S8B<2MxSDN)*20XLLnsw?SRr;t4)bRgWRs;a&ZgCib;cO4W&KM+q@XI3q3WB-_ zp7B2-k5Ue6vm71O$gf@%>|7iJRo)j1nNQ!AIYNgF3!hhxIWq7r3m*ISL#PhefQ}pV z!_j=RjfTjDUipQ&WO*5Z+oN43o5h`klNb^wpn(S>`=YDTcC$VT%S3P+jw5(}@fH&^ zW3|K72Fk%fSw_h3bGx&?kEKA%%jHgjL!@2TsiSesKq{ttCXtO+e%U?M`*AhJ=(njq zmK~DOmE%|51?ca7qsJd1MYr~eV?KTBrdl{Z{%L~X>^AdTl+eO-mk2m-%r8-Wr~aD& z3GtFoZP?`U3fsR#IdTCVU;rvcMv z`~|-gh4tD7&W8o(mqwX!_t`=XBh*ub^&yLCO$(!qinUmLgIVp76M=NaSi1%rwq9BX z7kVbBvJ+)6zuBspF*TtWyyK?Q;vh6MIxt^3@TJb=<(6=1@KdKEsg^HL{L!26<+pae zO0=X`UA5f2YnGh0%)Y$8Mxn^G#50O!5Szbzl~_zr;9F3pBtvTCOWPkwOTiZABi6S6 zx{c*mfFh@qMQ*kur^Gb3?A~qM1PJG!K5nbNQ=RKAV!2Z;q<#h{&a*C+3dn&rqLvLgr2x*{zL|CU=( zxEza|JCH`LxtlmMqC}?e)YTSo>}kiigOAQ z(g3_FSD^v`%`h#W7@8Zptwz$7<$MSq{aw~s`*eb?1cN;B&EQ$a0bJBUQ=l+YzqK%> zOUSki&VHm$!^)Yd>Z$~`08jon&gCs-RpKP);_RoP)#C)iJW0+#)n10B|~-6jh= z#Lp+!4PL(1R zint+Q_U}J2>$b9d0%!ZaYx_QQ{gybronzz<_Yrcq;fo}F8GQhcne5kTnRV+e9)QC3 z0goRBy-f_b0~qw$z`#pe{{HTe3jHzyyzi|-`RmT0Lw-nb`Wp0b`lEmM!ie#%KfXE0epmcPW;rCrl)0BlAm$B`$Aod%0F6W4(tZWIRFu&X5c^LvdR4@<{oYecfkHqzc z1=N3coGifLcruR{cto4OE6l~I3=i(&twUh+30@)x!Uj4n7BqHcxhyO+HW%AlmV5P? zK#+p9ZSv$ueAx_ZDU0c_yn*460h)KHNHDyFEsCZC9`<2n;MWg0^L_NJrjfvGa6Sdj zX>UH6nMdSv%{d4LV?#Lhsx1v(hTQN%@(;{N;Iv-wV! zv{Tx8nc(~0zw_H0q2P79b@_bN5|!O9gKDatSlUp#bD)cR`Td4R_jl@;r|kB4rNAdoj*iJe3 z9cbiCsIUCtcgv!}L*DPj+I=Q(!Mh&x3+=nc=l6_~Edw++EmS=Z`z?SIe&Qx|u8um} zblcG9L%)^+RF0c2Um?SFt@(X%mM5cHUED)W+k&=Oqm4W_)hjK6=!jQ6F>&vWwp;9C z39oHHm-6Mjj63m$ajl;EkgH)0Q>!B#rr;Mw zQd-SExAtZ>M_$j^E9&v7mSM-)9O;A!?B+(pN~R!@AH#3`$pq>LGjfyYTsN-ozgmt4 z7Xho4=NzRyz|Zy(VGW_#t7r>`iVRq_Z`!i1OpzlZFdH4c68nsdpqWj3&`Ih84ePql zgR9*%2ZTUk<_JdP3(e0r+PxTJl!(DBQS@^x{?F;5=n)li% zC#0GJp&>0E7`4x?9@6KpFMUgrW=CsYF2bwY5!RmQ{@T;XyiY}Mxs$zHtr5W3&I!Lp zzbgqrP*wz*>Qk&-WZg~|G4(TycjSbMZzS{8f4AHl-h)p3uPaqKJ!AKDV+-zgZGyNf zMKy8`#)Sq6Pd)D#p5>{&>TBXV4-dZit?QW>vj=L)hUr6%!SB-28e8d694{!& zE5q#DD3uUU?JqdDSzNg80-xM{i_8E0M_cfzvoxKW`|q`}jETZ?$}aq1Hyf-FoScC8 zUp88jO@>QjNp=7cy#GU4?c*NfKuP#t*W7-EGDGNu|6M0ai3WBiC*`@2Lm26Fg7~y9S)JI9EoZ$n(WNUSEsiMM5-G5 z61cH(XApD?B39EK3=O{D2pMwcyv>?sn*uj2^k;T19Y`ZRT@>xLReB0rzV?Qt zc!UP1%M+<;?w^dW>1R*(+5Es3w1;-J_VKKYa_0WtpfkFkX(<%^+w9hr%Jac0NV#|{ zxTh-^dB1w6C-kPPsITyOa8Incj;AgUBWFP+TE4b9kuLin2lRM&;pXP)K?XhL7$fb? zml@mj74rE6+0fwCV9n_`dyn@r37XeU&Tj7~xfJP$byX}7#a`ip!Y!(iPw9JNH3f#{ zWKrGKC|z&I&CPP2#S+A>x>TpPFp^I8ZPJw1sYD}ja|MotSpXZqA-|70(1rfjKc&~# P50IJDp%@T&1Q7oRtKV?> diff --git a/docs/design/agent-config-editing/spikes/model-usability/run.py b/docs/design/agent-config-editing/spikes/model-usability/run.py index b974a7877d..31afa66b85 100644 --- a/docs/design/agent-config-editing/spikes/model-usability/run.py +++ b/docs/design/agent-config-editing/spikes/model-usability/run.py @@ -360,6 +360,7 @@ def main() -> None: parser.add_argument("--rich-errors", action="store_true") parser.add_argument("--lenient", action="store_true") parser.add_argument("--v3-surface", action="store_true") + parser.add_argument("--v4-surface", action="store_true") parser.add_argument("--out", required=True) args = parser.parse_args() @@ -367,6 +368,7 @@ def main() -> None: harness.RICH_ERRORS = args.rich_errors or args.lenient harness.LENIENT = args.lenient harness.V3_SURFACE = args.v3_surface + harness.V4_SURFACE = args.v4_surface instructions = (HERE / "instructions" / f"{args.instructions}.md").read_text() schema = harness.tool_schema(union=args.union_schema) @@ -393,6 +395,7 @@ def work(item: Tuple["T.Task", int]) -> Dict[str, Any]: record["rich_errors"] = args.rich_errors record["lenient"] = args.lenient record["v3_surface"] = args.v3_surface + record["v4_surface"] = args.v4_surface with lock: handle.write(json.dumps(record, ensure_ascii=False) + "\n") handle.flush() diff --git a/docs/design/agent-config-editing/spikes/model-usability/selftest.py b/docs/design/agent-config-editing/spikes/model-usability/selftest.py index f081ca88d6..ae7169faa5 100644 --- a/docs/design/agent-config-editing/spikes/model-usability/selftest.py +++ b/docs/design/agent-config-editing/spikes/model-usability/selftest.py @@ -73,12 +73,12 @@ "value": { "name": "pdf-tools", "description": "Make and merge PDF files.", - "body": {"$content_from": "imports/pdf-tools/SKILL.md"}, + "body": {"$content_from": ".agenta-imports/pdf-tools/SKILL.md"}, "files": [ { "path": "reference.md", "content": { - "$content_from": "imports/pdf-tools/reference.md" + "$content_from": ".agenta-imports/pdf-tools/reference.md" }, } ], @@ -119,12 +119,12 @@ "value": { "name": "pdf-tools", "description": "Make and merge PDF files.", - "body": {"$content_from": "imports/pdf-tools/SKILL.md"}, + "body": {"$content_from": ".agenta-imports/pdf-tools/SKILL.md"}, "files": [ { "path": "reference.md", "content": { - "$content_from": "imports/pdf-tools/reference.md" + "$content_from": ".agenta-imports/pdf-tools/reference.md" }, } ], @@ -132,6 +132,29 @@ } ] }, + "l": { + "operations": [ + { + "operation": "add_item", + "target": AGENT + ["skills"], + "value": { + "name": "deploy-helper", + "description": "Deploy the service.", + "allow_executable_files": True, + "body": {"@ag.file": ".agenta-imports/deploy-helper/SKILL.md"}, + "files": [ + { + "path": "scripts/run.sh", + "content": { + "@ag.file": ".agenta-imports/deploy-helper/scripts/run.sh" + }, + "executable": True, + } + ], + }, + } + ] + }, "i": { "operations": [ { @@ -145,6 +168,7 @@ "name": "issue-triage", "description": "Triage incoming issues.", "body": "# Triage\n\nLabel the issue. Assign a priority.\n", + "allow_executable_files": False, "files": [], }, }, diff --git a/docs/design/agent-config-editing/spikes/model-usability/tasks.py b/docs/design/agent-config-editing/spikes/model-usability/tasks.py index a8a0954f7d..6eac5e84ab 100644 --- a/docs/design/agent-config-editing/spikes/model-usability/tasks.py +++ b/docs/design/agent-config-editing/spikes/model-usability/tasks.py @@ -60,10 +60,12 @@ "name": "release-qa", "description": "Run the release QA suite.", "body": RELEASE_QA_BODY, + "allow_executable_files": False, "files": [ { "path": "checklist.md", "content": "- [ ] smoke suite\n- [ ] deploy logs\n", + "executable": False, } ], }, @@ -71,12 +73,14 @@ "name": "changelog-writer", "description": "Write the changelog.", "body": CHANGELOG_BODY, + "allow_executable_files": False, "files": [], }, { "name": "triage", "description": "Triage incoming issues.", "body": "# Triage\n\nLabel the issue. Assign a priority.\n", + "allow_executable_files": False, "files": [], }, ], @@ -111,15 +115,24 @@ # The simulated workspace the runner can read. Keys are paths relative to the repo root. WORKSPACE: Dict[str, str] = { - "imports/pdf-tools/SKILL.md": ( + ".agenta-imports/pdf-tools/SKILL.md": ( "---\nname: pdf-tools\ndescription: Make and merge PDF files.\n---\n" "# PDF tools\n\nMake and merge PDF files.\n\n" "Use `pdftk` to merge. Use `weasyprint` to render HTML to PDF.\n" ), - "imports/pdf-tools/reference.md": ( + ".agenta-imports/pdf-tools/reference.md": ( "# Reference\n\n- merge: `pdftk a.pdf b.pdf cat output out.pdf`\n" "- render: `weasyprint in.html out.pdf`\n" ), + ".agenta-imports/deploy-helper/SKILL.md": ( + "---\nname: deploy-helper\ndescription: Deploy the service.\n---\n" + "# Deploy helper\n\nRun `scripts/run.sh` to deploy.\n\n" + "Check the health endpoint afterwards.\n" + ), + ".agenta-imports/deploy-helper/scripts/run.sh": ( + "#!/usr/bin/env bash\nset -euo pipefail\n" + 'echo "deploying $1"\ncurl -fsS "$DEPLOY_HOOK"\n' + ), # The same folder, misplaced. Task (h) points here first. "scratch/pdf-tools/SKILL.md": ( "---\nname: pdf-tools\ndescription: Make and merge PDF files.\n---\n" @@ -127,7 +140,7 @@ ), } -IMPORT_ROOT = "imports/" +IMPORT_ROOT = ".agenta-imports/" # -------------------------------------------------------------------------------------- @@ -364,6 +377,37 @@ def check_k(config: Dict[str, Any]) -> Optional[str]: return None +def check_l(config: Dict[str, Any]) -> Optional[str]: + skills = agent(config)["skills"] + skill = find(skills, "name", "deploy-helper") + if skill is None: + return f"the deploy-helper skill is missing; found {[s.get('name') for s in skills]}" + if len(skills) != 4: + return f"expected 4 skills, found {len(skills)}" + if "@ag.file" in str(skill): + return "an unresolved content marker survived into the config" + + files = skill.get("files") or [] + script = find(files, "path", "scripts/run.sh") + if script is None: + return f"scripts/run.sh is missing; found {[f.get('path') for f in files]}" + if "curl -fsS" not in (script.get("content") or ""): + return "scripts/run.sh does not carry the file content" + + if script.get("executable") is not True: + return f"the file is not marked executable (executable={script.get('executable')!r})" + if skill.get("allow_executable_files") is not True: + return ( + "the skill does not allow executable files " + f"(allow_executable_files={skill.get('allow_executable_files')!r})" + ) + + body = skill.get("body") + if not isinstance(body, str) or "scripts/run.sh" not in body: + return "the skill body does not carry the SKILL.md content" + return unchanged_except(config, allowed=["skills"]) + + class Task: def __init__( self, @@ -419,7 +463,7 @@ def __init__( Task( "e", "add a skill from workspace files", - "I wrote a new skill in the workspace at imports/pdf-tools/. It has " + "I wrote a new skill in the workspace at .agenta-imports/pdf-tools/. It has " "SKILL.md (the skill body) and reference.md (a bundled file). Add it as a skill " "named pdf-tools with the description 'Make and merge PDF files.'. Do not " "retype the file contents; pull them from those paths.", @@ -453,6 +497,15 @@ def __init__( check_h, recovery="import_root", ), + Task( + "l", + "add a skill with an executable script", + "Add the deploy-helper skill from .agenta-imports/deploy-helper/. It has " + "SKILL.md (the skill body) and scripts/run.sh. Its scripts/run.sh must be " + "runnable as a program. Do not retype the file contents; pull them from those " + "paths.", + check_l, + ), Task( "i", "rename a skill, keeping its content", From ea448a45122ed6648a3d46f3f423dfaa11f4a511 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Wed, 5 Aug 2026 12:13:33 +0200 Subject: [PATCH 21/36] docs(design): change-set contract consolidated to final decided state; cross-contract banners updated @ag.file marker, .agenta-imports root, list selector key with wrapper forgiveness, per-class match tolerance with length-preserving folds, derived commit message, split error codes with next-steps, platform-tool rejection, uniform reopen note, v3 instructions folded in as normative. --- .../contracts/change-set.md | 1049 +++++++++-------- .../contracts/execution-authorization.md | 81 +- .../contracts/workspace-import.md | 57 +- 3 files changed, 649 insertions(+), 538 deletions(-) diff --git a/docs/design/agent-config-editing/contracts/change-set.md b/docs/design/agent-config-editing/contracts/change-set.md index 3f201dcfc5..9d85d005a9 100644 --- a/docs/design/agent-config-editing/contracts/change-set.md +++ b/docs/design/agent-config-editing/contracts/change-set.md @@ -1,24 +1,22 @@ # Contract: the change set -Status: proposed. It answers must-fix item 1 of the design gate review. -Owner: engine-spike. Date: 4 August 2026. +Status: **consolidated to the decided state, 5 August 2026.** It supersedes every earlier +version of this file, `research/change-set-interface-codex.md`, and the matching parts of +`spikes/engine-spike.md`. Section 19 records what changed and why. +Owner: engine-spike. -This document is the one authoritative change-set contract. Where it disagrees with -`research/change-set-interface-codex.md`, `spikes/engine-spike.md`, or `decisions.md`, -this document wins. Section 12 lists the changes the prototype needs. +Where this document disagrees with any other document, this one wins. -## 1. Scope +## 1. Scope and layers The change set describes a change to one workflow revision's data tree. It is data only. -It does not say where the base comes from. It does not say what happens after the change. +It does not say where the base comes from, and it does not say what happens after. -Three layers use it: - -| Layer | Owns | -|---|---| -| The engine (`apply_change_set`) | Applies the change set to a base tree. Pure. No I/O. | -| The commit wrapper | The base check, the transaction, the response. See `commit-transaction.md`. | -| The runner | Turns `value_from` into an inline `value` before the API sees the call. | +| Layer | Owns | Refuses | +|---|---|---| +| The runner | Resolves every `@ag.file` marker into an inline string before the API sees the call. | A path outside the workspace; a missing or unreadable file. | +| The wrapper | The base check, the scope policy, the derived message, the transaction, the response. | Out-of-scope targets; platform-kind tool entries; a stale base. | +| The engine (`apply_change_set`) | Applies the change set to a base tree. Pure. No I/O. | Everything in section 12 that is not wrapper-owned; any surviving `@ag.file`. | The engine never reads a path, never reads a database, and never writes one. @@ -32,61 +30,41 @@ hides it from the model. "workflow_revision": { "workflow_variant_id": "019c...", "base_revision_id": "019c...", - "message": "Update the release QA instructions.", "delta": { } - } + }, + "description": "Adding the pdf-tools skill you asked for." } ``` -- `base_revision_id` is a precondition on the commit. It is not part of the delta. - It is required when `delta` uses the ordered form. `commit-transaction.md` section 8 - defines how a legacy call gets a default. -- `message` is the persisted commit message. -- The ephemeral per-call `description` is NOT in this envelope. See `read-config.md` - section 12. +- `base_revision_id` is a precondition on the commit, not a mutation. An ordered delta + requires it. The model copies it from the `read_config` response + (`read-config.md` section 10.1). +- **There is no `message` field.** The server derives the commit message from the + operations. Section 14. +- `description` is the ephemeral per-call note (R12). It rides the tool-call envelope, not + the revision, and the runner strips it before it builds the request + (`read-config.md` section 12). ### 2.1 What the catalog advertises -The model-visible schema is `_COMMIT_REVISION_INPUT_SCHEMA` in -`sdks/python/agenta/sdk/agents/platform/op_catalog.py`. It is closed -(`additionalProperties: false`) at every level. It must advertise exactly this set: +`_COMMIT_REVISION_INPUT_SCHEMA` in +`sdks/python/agenta/sdk/agents/platform/op_catalog.py` is closed +(`additionalProperties: false`) at every level. It advertises exactly this: | Field | Model-visible | Note | |---|---|---| | `workflow_revision.workflow_variant_id` | no | Bound from `$ctx.workflow.variant.id` and stripped. | -| `workflow_revision.base_revision_id` | yes | An ordered delta needs it. The model copies it from the `read_config` response. `read-config.md` section 10.1. | -| `workflow_revision.message` | yes | | +| `workflow_revision.base_revision_id` | yes | Copied from the read. | | `workflow_revision.delta` | yes | The `oneOf` of section 3. | -| `value_from.type` | yes | `"workspace"`. On `set`, `add_item`, and `replace_item`. | -| `value_from.path` | yes | Relative to the import root. A folder for the item verbs, one file for `set`. | -| `value_from.on_unsupported` | yes | `"reject"` (default) or `"omit"`. Folder source only. Section 5.1.3. | -| `value_from.on_executable` | yes | `"reject"` (default) or `"import"`. Folder source only. Section 5.1.3. | -| `value_from.persist_executable_capability` | yes | Boolean, default `false`. Folder source only. It needs `on_executable: "import"`. Section 5.1.3. | - -The union carries two source schemas, one per shape: - -| Operation member | `value_from` | Fields | -|---|---|---| -| `add_item`, `replace_item` | the folder source | `type`, `path`, and the three policy fields | -| `set` | the file source | `type` and `path` only | -| `merge`, `remove`, `edit_text`, `remove_item` | none | the member must not offer the field | - -Section 5.1 gives the reason for each row. Section 5.1.1 lists the three conditions a -`set` source must meet, and section 5.1.3 explains why the file source carries no policy -fields. +| `description` | yes | Ephemeral, stripped by the runner before dispatch. | -The three policy fields must appear here, or the model cannot set them and the defaults -become the only reachable behavior. The runner strips the whole `value_from` object during -resolution, so no `value_from` field ever reaches the API. +Nothing else is model-visible. `message`, `data`, `flags`, `name`, `tags`, and `meta` are +all off the model surface. -`allow_executable_files` is no longer a `value_from` field. It is now only the persisted -`SkillTemplate.allow_executable_files`, and an import sets it only through -`persist_executable_capability`. `workspace-import.md` section 5.2 owns the four-layer -split that this follows. - -Nothing else is model-visible. `data`, `flags`, `name`, `description`, `tags`, and `meta` -stay off the model surface. `read-config.md` section 11 defines the second gate, the scope -policy, which closes the fields a `delta` could otherwise still reach. +**Why `message` left.** It was optional, and the model volunteered one anyway and still +corrupted it. Free text was the site of every argument-corruption failure the usability +spike measured. A derived message is also more accurate than a written one, which serves +issues #5187 and #5200 better than the model's own words. ## 3. The delta: two forms, never mixed @@ -114,8 +92,8 @@ policy, which closes the fields a `delta` could otherwise still reach. ``` Behavior does not change. `set` deep-merges with the dict-only recursion. Scalars and -lists replace. `remove` deletes dotted paths. A missing remove path stays a silent no-op. -The order is `set`, then `remove`. +lists replace. `remove` deletes dotted paths, and a missing path stays a silent no-op. The +order is `set`, then `remove`. ### 3.2 OrderedDelta @@ -163,10 +141,10 @@ A target is a non-empty array of segments. { "type": "object", "additionalProperties": false, - "required": ["field", "key"], + "required": ["list", "key"], "properties": { - "field": { "type": "string", "minLength": 1 }, - "key": { "type": "string", "minLength": 1 } + "list": { "type": "string", "minLength": 1 }, + "key": { "type": "string", "minLength": 1 } } } ] @@ -174,18 +152,23 @@ A target is a non-empty array of segments. } ``` -A string segment addresses an object field. An object segment addresses one named entry -in the list at `field`. Example: +A string segment names an object field. An object segment names one entry of a list, and +**it stands in place of that list's name**: ```json -["parameters", "agent", {"field": "skills", "key": "release-qa"}, "body"] +["parameters", "agent", {"list": "skills", "key": "release-qa"}, "body"] ``` -### 4.1 Key fields per collection +**The selector key is `list`, not `field`.** The usability spike measured the selector as +the cause of 62 percent of all failures, and every one of those was about which list the +segment replaces. `list` says it. No model in the measurement ever misused `list`, and the +key-field mistake disappeared. + +### 4.1 Keyed lists -Only these four collections take a selector segment and item operations. +Only these four lists take a selector and item operations. -| Collection | Key | +| List | Key | |---|---| | `skills` | `name` | | `mcps` | `name` | @@ -196,216 +179,58 @@ Any other list has no key. A selector on it gives `unkeyed_collection`. ### 4.2 The canonical tool name -One function, `item_key("tools", entry, allow_legacy_fallback)`. The SDK and the server -must share one implementation and one golden fixture set. +One function, `item_key("tools", entry, allow_legacy_fallback)`, shared by the SDK and the +server with one golden fixture set. | Tool `type` | Key | |---|---| -| `gateway` | `name`. When `name` is absent and `allow_legacy_fallback` is true: `{integration}__{action}`. | +| `gateway` | `name`. Absent and reading: the legacy `{integration}__{action}`. | | `reference` | `name`, else `slug`. | | `platform` | `op`. | | `code`, `client`, `builtin` | `name`. | -| an `@ag.embed` object | none. The entry is not addressable. | - -`allow_legacy_fallback` is true when the engine READS the tree to find an entry. It is -false when the engine DERIVES the key of a value the caller supplies. So an old unnamed -gateway entry stays addressable, and a new one must carry an explicit `name`. - -An entry with no derivable key is skipped during a search. It never matches, and it never -collides. - -## 5. The seven operations - -### 5.1 Value sources - -| Operation | `value` | `value_from` | Source shape | -|---|---|---|---| -| `set` | yes | yes, restricted | exactly one file. Section 5.1.1. | -| `merge` | yes | **no** | — | -| `remove` | no | no | — | -| `edit_text` | no | no | — | -| `add_item` | yes | yes | one folder, converted to an item. | -| `replace_item` | yes | yes | one folder, converted to an item. | -| `remove_item` | no | no | — | - -The rule follows the approval screen, not the engine. A human approves an import before the -runner reads the bytes. The human must therefore see a readable change, never a byte count -and a path. +| an `@ag.embed` object | none. Not addressable. | -`merge` does not take `value_from` at all. A source materializes a whole object. A deep -merge of a whole materialized object into an existing object hides which fields survived. -The result depends on the folder content, and the human who approves the call cannot see -it. +`allow_legacy_fallback` is true when the engine READS the tree to find an entry, and false +when it DERIVES the key of a value the caller supplies. So an old unnamed gateway entry +stays addressable, and a new one must carry an explicit `name`. -#### 5.1.1 `set` with `value_from`: three conditions, all required +An entry with no derivable key never matches and never collides. -The team lead decided this on 4 August, in answer to gate 2, new problem 9. The oversized -instruction file is the founding use case of this project (#5554), so `set` must have a -path for it. +### 4.3 The wrapper forgives two selector mistakes -`set` accepts `value_from` only when all three conditions hold. The runner refuses the -call before it reads any content if any one of them fails. +Both are unambiguous, so a refusal would teach nothing. The wrapper normalizes the target +before the engine sees it, and it adds a warning so the correction is visible. -**Condition 1: the source resolves to exactly one file.** Never a folder. A folder has no -single text to show, and the file-manifest presentation belongs to the item verbs. A source -path that names a directory is `source_invalid`. A source path that matches more than one -file is `source_invalid`. +| Mistake | Example | Normalized to | +|---|---|---| +| The list name repeated before the selector | `["...","skills",{"list":"skills","key":"x"}]` | `["...",{"list":"skills","key":"x"}]` | +| The key field in the `list` slot | `["...",{"list":"name","key":"x"}]` inside a known list position | the enclosing list's name | -**Condition 2: the target's last segment is a string-typed field.** The value replaces one -long-text field, not a structure. Four target shapes are allowed: +The first absorbed 12 percent of one model's targets once the teaching left the tool +description. The second vanished when `field` became `list`, and the normalization stays as +a belt. -| Field | Target | -|---|---| -| the instructions | `["parameters","agent","instructions","agents_md"]` | -| a skill body | `[...,{"field":"skills","key":K},"body"]` | -| a skill file's content | `[...,{"field":"skills","key":K},{"field":"files","key":P},"content"]` | -| a code tool's script | `[...,{"field":"tools","key":N},"script"]` | - -The field must already exist and must already hold a string. Parent creation -(section 5.3) does not apply to a `set` that carries `value_from`: a missing field is -`target_not_found`, and a non-string field is `target_type_mismatch`. A field that does not -exist yet has no old text, so it has no honest diff. Use `add_item` for a new skill file. - -**Condition 3: the approval shows a unified diff of the old text against the new text, and -the old text comes from the exact revision the operation names.** The card presents a -readable change: the target field, the diff, the line counts, and the digest of the exact -bytes that will be committed. It must not present a byte count alone. -`workspace-import.md` section 8.4 owns the presentation. - -**The old side must come from `base_revision_id`, and from nothing else.** Gate 3, finding -2 and arbitration ruling 1, corrected this. The gate 2 draft took the old text from the -configuration the runner holds for the current run. That is wrong, and the error is silent: - -- The session runs revision N. The model reads the head, which is N+1, and correctly puts - N+1 in `base_revision_id`. -- The runner renders the diff from its own memory, so the human approves an N-to-new diff. -- The base check passes, because the base really is the head. The commit replaces N+1. -- The user approved one change and got another. Nothing reports it. - -The base check cannot catch this, because the base is not stale. Only the diff is. - -So the rule is exact: - -1. The runner fetches the old text from the revision named by `base_revision_id`, at the - operation's target path. `read_config` is the natural way to fetch it. -2. If `base_revision_id` is absent, the call fails. A single-text `set` from a workspace - file needs a named base. This is not a new burden: an ordered delta already requires - `base_revision_id` (`read-config.md` section 10.1). -3. **If the runner cannot fetch that revision's text, for any reason, the call fails - closed.** It does not fall back to session memory. It does not fall back to the complete - new text. It does not fall back to a byte count. It refuses, with - `source_diff_base_unavailable`, and it reads no workspace bytes. - -Failing closed is the right cost here. A refused call tells the agent to retry, and a retry -costs one turn. A diff against the wrong base commits the wrong content with a human -signature on it, and nothing detects it afterwards. - -An alternative exists, and this contract does not choose it: the API could accept a digest -of the approved old value and refuse the commit when the stored value does not match. That -moves the check to the server and survives a lying runner. It also adds a field to the -commit envelope and a second failure mode. Revisit it if the runner-side fetch proves -unreliable. - -#### 5.1.2 Folder into `set` stays disallowed - -A folder source into a `set` target is refused, and it stays refused. There is no honest -presentation for it. A folder carries many files, and a `set` target is one field. The card -would have to either flatten the folder into one value, which the human cannot review, or -list the files without showing what each one becomes, which is the byte-count-and-path -approval that condition 3 exists to prevent. The item verbs already carry folders, and they -carry them with an item identity the card can name. - -A value-bearing operation carries exactly one of `value` and `value_from`. Both is -`invalid_operation`. Neither is `invalid_operation`. A `value_from` on `merge`, `remove`, -`edit_text`, or `remove_item` is `invalid_operation`, and the schema refuses it first. - -The engine refuses `value_from` with `source_invalid`. The runner must resolve it first. - -#### 5.1.3 Two source schemas, one per source shape - -The folder source, on `add_item` and `replace_item`: +Normalization is the wrapper's job, not the engine's. The engine takes a clean target, so +its behavior stays exactly describable. Warning code: `target_normalized`. -```json -{ - "type": "object", - "additionalProperties": false, - "required": ["type", "path"], - "properties": { - "type": { "const": "workspace" }, - "path": { "type": "string", "minLength": 1 }, - "on_unsupported": { "enum": ["reject", "omit"], "default": "reject" }, - "on_executable": { "enum": ["reject", "import"], "default": "reject" }, - "persist_executable_capability": { "type": "boolean", "default": false } - } -} -``` +## 5. The seven operations -The file source, on `set`: +### 5.1 Values -```json -{ - "type": "object", - "additionalProperties": false, - "required": ["type", "path"], - "properties": { - "type": { "const": "workspace" }, - "path": { "type": "string", "minLength": 1 } - } -} -``` +| Operation | Needs | May contain `@ag.file` | +|---|---|---| +| `set` | `value` | yes, anywhere a string may go | +| `merge` | `value` (an object) | yes | +| `remove` | — | — | +| `edit_text` | `edits` | no. Section 6.4 | +| `add_item` | `value` | yes | +| `replace_item` | `value` | yes | +| `remove_item` | — | — | -The three folder-source policy fields, in one line each: +There is no `value_from`, and there is no source object on the operation. Section 6 +explains the marker that replaced it and why. -| Field | Meaning | -|---|---| -| `on_unsupported` | `"reject"` (default) refuses a folder that holds an unsupported file. `"omit"` imports the rest and lists every omission. | -| `on_executable` | `"reject"` (default) refuses a folder that holds an executable file. `"import"` imports the folder and records the observed bits. | -| `persist_executable_capability` | `false` (default) commits `SkillTemplate.allow_executable_files` as false. `true` commits it as true. | - -**One constraint binds the last two: `persist_executable_capability: true` needs -`on_executable: "import"`.** The reverse is allowed. A caller may import the bits without -granting the runtime capability, which gives a faithful copy of the folder that still -cannot execute anything. A caller may not grant the runtime capability for bits it never -permitted itself to read. A violation is `invalid_operation`, and the runner refuses it -before any workspace read. - -The two fields are separate because they are two grants with two owners and two lifetimes. -`on_executable` is an import grant: the caller and the human approver own it, and it dies -with the operation. `persist_executable_capability` writes a stored capability that lives -for the life of the revision. `workspace-import.md` section 5.2 defines the four-layer -split this follows, and it shows the two as separate lines on the approval card. - -The file source carries no policy fields, because none of the three has a meaning for it: - -- `on_unsupported` chooses between refusing a folder and omitting some of its files. A - single-file source has nothing to omit. An unsupported single file always rejects, with - `source_unsupported_content`. -- `on_executable` grants an import the right to carry executable bits. A `set` writes text - into an existing string field. It creates no file entry, so it carries no bit. -- `persist_executable_capability` writes `SkillTemplate.allow_executable_files`. A `set` - never writes a skill template. It writes one long-text field inside an existing one. - -The three are import-policy declarations on the folder source. The runner's import -resolver consumes them. `workspace-import.md` section 4.2 defines `on_unsupported` and its -default, section 4.3 defines the `omit` opt-in, and section 5.2 defines `on_executable`, -`persist_executable_capability`, and the constraint between them. - -Three points fix their place: - -1. **They sit on the source, not on the operation.** One commit can import two folders and - give each one a different answer. A field on the operation could not do that. With - `value_from` the runner generates the whole value, so the caller has no `value` object - to write `persist_executable_capability` into either. -2. **The engine never sees them.** The runner resolves `value_from` and then strips the - whole `value_from` object. It puts a plain inline `value` in place of it. So these - fields never reach the API, and the engine surface does not grow. The engine still - refuses any `value_from` that survives, with `source_invalid`. -3. **The model must be able to write them.** This schema is model-facing. With - `additionalProperties: false` and no such fields, the defaults would be the only - reachable behavior. A skill folder with one binary asset, or with one script, would - then be permanently uncommittable. - -This resolves the conflict `workspace-import.md` section 11 raises against this section. +A value-bearing operation must carry `value`. A missing value is `missing_operation_value`. ### 5.2 The last target segment @@ -415,10 +240,8 @@ This resolves the conflict `workspace-import.md` section 11 raises against this | `add_item` | a string | the list to append to | | `replace_item`, `remove_item` | a selector | one named entry | -A wrong tail is `invalid_operation`. This keeps one intent per verb. Without it, `set` on -a selector would do the work of `replace_item`. - -A selector may appear at any earlier position, for every operation. +A wrong tail is `invalid_target_shape`, which is retryable and carries the correct shape in +its next step. A selector may appear at any earlier position, for every operation. ### 5.3 `set` @@ -435,24 +258,10 @@ Replaces the target value exactly. `value: null` writes null; it does not remove **Parent creation.** `set` creates missing parents, under strict rules: 1. It creates only plain-string segments, and only as `{}`. -2. It never creates through a selector. If any segment on the path is a selector, every - segment up to and including that selector must already resolve. A missing selector is - always `item_not_found` or `target_not_found`. +2. It never creates through a selector. Every selector on the path must already resolve. 3. It never creates a list, and never creates a list entry. -4. An existing parent that is a scalar, a list, or null is `target_type_mismatch`. The - engine does not overwrite it with `{}`. -5. Final validation stays mandatory. Parent creation is a convenience, not a licence to - invent fields. The closed agent template rejects an invented path at validation. -6. Parent creation does not apply when the operation carries `value_from`. That form needs - an existing string target, so it has an old text to diff. Section 5.1.1. - -Example. With `harness: {"kind": "pi_agenta"}` in the base: - -- `set ["parameters","agent","harness","extras","system"] = "..."` creates `extras` as - `{}`, then writes `system`. It succeeds. -- `set ["parameters","agent","nope","x"] = 1` creates `nope` as `{}`, writes `x`, and - then fails final validation with `final_validation_failed`. -- `set ["uri","deeper"] = 1` fails with `target_type_mismatch`, because `uri` is a string. +4. An existing parent that is a scalar, a list, or null is `target_type_mismatch`. +5. Final validation stays mandatory. The closed agent template rejects an invented path. ### 5.4 `merge` @@ -464,10 +273,8 @@ Example. With `harness: {"kind": "pi_agenta"}` in the base: } ``` -Deep-merges an object with today's dict-only recursion. Nested dicts merge. Scalars and -lists replace. The target must exist and must be an object. `merge` never creates -parents. A missing target is `target_not_found`. A non-object target is -`target_type_mismatch`. A non-object `value` is `invalid_operation`. +Deep-merges an object with the dict-only recursion. Nested dicts merge; scalars and lists +replace. The target must exist and must be an object. `merge` never creates parents. ### 5.5 `remove` @@ -475,8 +282,8 @@ parents. A missing target is `target_not_found`. A non-object target is { "operation": "remove", "target": ["parameters", "agent", "llm", "extras"] } ``` -Removes one object field. A missing field is `target_not_found`. This differs from the -legacy `remove`, which stays a silent no-op. +Removes one object field. A missing field is `target_not_found`. The legacy `remove` stays +a silent no-op; this one does not. ### 5.6 `edit_text` @@ -484,7 +291,7 @@ legacy `remove`, which stays a silent no-op. { "operation": "edit_text", "target": ["parameters", "agent", "instructions", "agents_md"], - "match_mode": "exact", + "match_mode": "auto", "edits": [ {"old_text": "Run the checks manually.", "new_text": "Run the release-qa skill."} ] @@ -493,7 +300,7 @@ legacy `remove`, which stays a silent no-op. ```json { - "match_mode": { "type": "string", "enum": ["exact"], "default": "exact" }, + "match_mode": { "type": "string", "enum": ["auto", "exact"], "default": "auto" }, "edits": { "type": "array", "minItems": 1, "maxItems": 32, "items": { @@ -508,55 +315,84 @@ legacy `remove`, which stays a silent no-op. } ``` -`match_mode` is optional. The default is `exact`. Only `exact` is valid today. The engine -dispatches on the value through a table; it must not ignore the field. An unknown mode is -`invalid_operation`, even if the schema also rejects it. A later mode is then additive. - The target must be a string. Anything else is `target_type_mismatch`. Rules, in order: -1. `old_text` must not be empty. Empty gives `empty_old_text`. -2. Matching is exact on the code points. Nothing is normalized. The engine does not apply - NFKC or NFC. It does not fold smart quotes, dashes, or special spaces. It does not trim - trailing whitespace. It does not fold CRLF to LF. It does not strip a BOM. -3. `old_text` must occur exactly one time, counted with overlap. See section 5.6.1. +1. `old_text` must not be empty. Empty is `empty_old_text`. +2. Each anchor is matched by the tolerance its target's content class allows. Section 5.6.1. +3. Each anchor must occur exactly once, counted **with overlap**. Section 5.6.2. 4. Every anchor matches the string as it was before this operation started. 5. Matches must not overlap. Adjacent matches are legal. -6. The engine applies the matches from the highest index to the lowest. -7. The batch must change the string. No change gives `no_change`. One edit that changes - nothing is fine, if another edit in the same batch changes something. +6. The engine applies matches from the highest index to the lowest. +7. The batch must change the string. No change is `no_change`. One edit that changes + nothing is fine if another edit in the same batch changes something. 8. The batch is atomic. One bad edit leaves the string untouched. -#### 5.6.1 Overlap-aware occurrence counting +#### 5.6.1 Match tolerance by content class -`str.count` counts without overlap. It reports one occurrence of `"aa"` in `"aaa"`. Two -start positions exist, so the anchor is ambiguous. The engine must count every start -position: +Stored bytes are never normalized (decision 1, option A). The tolerance lives in matching +only, and it depends on what the text is. + +| Content class | Fields | Tolerance | +|---|---|---| +| **Prose** | `instructions.agents_md`, a skill `body`, any `description` | exact first; on no exact match, one normalized retry | +| **Code and data** | a skill file's `content`, a code tool's `script` | exact only | + +Prose is written by humans and by models, and a smart quote that arrived through a +different editor should not block an edit. A script's bytes are its meaning: a normalized +match there could rewrite a string literal or a shell quote into something that no longer +runs. + +The normalized retry: + +- It folds smart quotes to ASCII quotes, Unicode dashes to the ASCII hyphen, and Unicode + spaces to the ASCII space. +- The normalized match must still be **unique**, counted with overlap. Two normalized + matches are `text_not_unique`, exactly as two exact matches would be. +- The write is still byte-exact. The engine replaces the matched span of the ORIGINAL + string. It never writes normalized bytes back, and it never touches a byte outside the + span. +- The response reports it: warning code `text_matched_normalized`, naming the operation + index and the edit index. The human and the agent both learn that the anchor was not + literal. + +**Every normalization is one code point to one code point.** This is a deliberate +constraint, and it is what makes "byte-exact write" true. A length-changing normalization +(trailing-whitespace trim, run collapsing, CRLF folding) would put the match at an offset +that does not exist in the original string, and recovering the original offsets needs the +line-overlay machinery whose corruption risk is the reason this design rejected Pi's +approach. So: + +- Not folded: trailing whitespace, repeated spaces, CRLF against LF, Unicode NFC against + NFD. Section 18 records the two of these that may deserve a later answer. + +`match_mode` selects the policy: `auto` (the default) applies the table above; `exact` +forces exact matching on every class. The engine dispatches on the value through a table +and never ignores the field. An unknown mode is `unknown_operation`. + +#### 5.6.2 Overlap-aware occurrence counting + +`str.count` counts without overlap and reports one occurrence of `"aa"` in `"aaa"`. Two +start positions exist, so the anchor is ambiguous. The engine counts every start position: ```text -count = 0 -i = 0 -while True: - i = text.find(old_text, i) - if i < 0: break +count = 0; i = 0 +while (i = text.find(old_text, i)) >= 0: count += 1 i += 1 # advance by one, not by len(old_text) ``` -Two or more positions give `text_not_unique` with `match_count`. Zero gives -`text_not_found`. - -#### 5.6.2 Work limits +Two or more is `text_not_unique` with `match_count`. Zero is `text_not_found`. -The scan costs O(n·m). The engine enforces limits before it scans: +#### 5.6.3 Work limits | Limit | Value | Error | |---|---|---| | target string length | 200 000 code points | `text_too_large` | -| `old_text` length | 20 000 code points | schema, then `invalid_operation` | -| edits per operation | 32 | schema, then `invalid_operation` | -| operations per delta | 64 | schema, then `invalid_delta` | +| `old_text` length | 20 000 code points | schema, then `invalid_operation_shape` | +| edits per operation | 32 | schema | +| operations per delta | 64 | schema | The string limit matches `SkillFile.content` (`max_length=200_000`). @@ -566,14 +402,18 @@ The string limit matches `SkillFile.content` (`max_length=200_000`). { "operation": "add_item", "target": ["parameters", "agent", "skills"], - "value": {"name": "pdf-tools", "description": "Make PDFs.", "body": "..."} + "value": { + "name": "pdf-tools", + "description": "Make PDFs.", + "body": {"@ag.file": ".agenta-imports/pdf-tools/SKILL.md"} + } } ``` -Appends one entry. The target must resolve to a list. The field name must be a keyed -collection, or the result is `unkeyed_collection`. The engine derives the key from the -value with `allow_legacy_fallback=false`. No key gives `item_key_undefined`. An existing -entry with that key gives `item_already_exists`. +Appends one entry. The target must resolve to a list, and the list must be keyed +(section 4.1) or the result is `unkeyed_collection`. The engine derives the key from the +value with `allow_legacy_fallback=false`; no key is `item_key_undefined`. An existing entry +with that key is `item_already_exists`. There is no position field. The new entry goes to the end. @@ -582,37 +422,134 @@ There is no position field. The new entry goes to the end. ```json { "operation": "replace_item", - "target": ["parameters", "agent", {"field": "skills", "key": "release-qa"}], + "target": ["parameters", "agent", {"list": "skills", "key": "release-qa"}], "value": {"name": "release-qa", "description": "...", "body": "..."} } ``` -Replaces one existing entry. A missing entry gives `item_not_found`. The key derived from -the value must equal the key in the target. A difference gives `invalid_operation`. A -rename is `remove_item` plus `add_item`. +Replaces one existing entry. A missing entry is `item_not_found`. The key derived from the +value must equal the key in the target; a difference is `item_rename_not_allowed`, which is +retryable and whose next step is "send `remove_item` then `add_item`". ### 5.9 `remove_item` ```json { "operation": "remove_item", - "target": ["parameters", "agent", {"field": "tools", "key": "send-slack-message"}] + "target": ["parameters", "agent", {"list": "tools", "key": "send-slack-message"}] +} +``` + +Removes one existing entry. A missing entry is `item_not_found`. + +## 6. The `@ag.file` marker + +### 6.1 The shape + +```json +{"@ag.file": ""} +``` + +It replaces **any string** inside an operation's `value`. The runner reads the file and +puts its text there, before the API sees the call. + +```json +{ + "operation": "add_item", + "target": ["parameters", "agent", "skills"], + "value": { + "name": "pdf-tools", + "description": "Make PDFs.", + "body": {"@ag.file": ".agenta-imports/pdf-tools/SKILL.md"}, + "files": [ + { + "path": "scripts/extract.py", + "content": {"@ag.file": ".agenta-imports/pdf-tools/scripts/extract.py"}, + "executable": true + } + ] + } } ``` -Removes one existing entry. A missing entry gives `item_not_found`. +`@ag.file` joins `@ag.embed` as one marker family: same shape, different lifetime. An embed +persists in the configuration and re-resolves on every read. A file marker is consumed at +commit and never persists — the committed revision holds the text. -## 6. Application +### 6.2 What it replaced, and why -Operations run in array order. Each operation sees the result of the operations before -it. The first failing operation aborts the whole change set. The engine returns nothing -partial. The caller's base tree never changes: the engine deep-copies it first, and -deep-copies every value it writes. +The earlier design put a `value_from` source object on the operation, which resolved a +whole FOLDER through a codec into a skill, and carried import-policy fields. -## 7. What the engine returns +The usability spike measured both. The operation-level source produced the only +silent-corruption failure mode in the whole study. The marker went 91 for 91 across both +models. So: -This replaces D33 in `spikes/engine-spike.md` and settles the contradiction the review -found. The engine has a warning channel. The engine does not own the response. +- **There is no folder source and no folder-to-skill codec in v1.** The agent authors the + skill structure itself and references each file's content per field. It already knows the + structure; it does not need a codec to infer it. +- **There are no policy fields.** `on_unsupported`, `on_executable`, and + `persist_executable_capability` are all gone. Each marker is one file. An unsupported file + fails its own marker with a clear reason, and the all-or-nothing commit guarantees that + nothing partial ever lands, so there is nothing for an "omit" mode to buy. +- **`executable` is an ordinary agent-authored field**, on the skill file entry, exactly + like `path` and `content`. So is the skill's `allow_executable_files`. The approval card + must display both. They are configuration the human reads, not a policy grammar the model + must learn. + +### 6.3 Paths + +The import root is **`.agenta-imports/`** under the run's working directory. A dot-folder +stays out of shell listings by default, and the Files drawer already hides the `.agenta-*` +prefix, so a non-technical user never sees a confusing system folder. + +A path may be written two ways, and the runner normalizes both: + +| Form | Example | +|---|---| +| relative to the workspace root | `.agenta-imports/pdf-tools/SKILL.md` | +| absolute inside the workspace | `/workspace/.agenta-imports/pdf-tools/SKILL.md` | + +Only a path that resolves outside the workspace is refused. Agents write absolute paths +naturally; refusing them fights the model for nothing, and the runner knows its own root on +each platform. + +The runner still confines every resolved path, refuses symbolic links, and reads each file +once. `workspace-import.md` owns the confinement mechanics. + +### 6.4 Where the marker may not appear + +- **Not in `edit_text`.** An anchor must be text the model read and copied. A file marker + there would anchor against content the model never saw. +- **Not in a target.** A target is addressing, not content. +- **Not in a legacy `set` tree.** The legacy form is frozen. + +### 6.5 The engine refuses every surviving marker + +The engine is pure and never reads a path. If a marker reaches it, the runner did not +resolve it, and the safe answer is to refuse the whole commit: +`unresolved_file_marker`, not retryable. Silently storing `{"@ag.file": "..."}` as a +configuration value would ship a broken agent. + +The engine scans the whole value of every operation, at every depth, including inside +lists. + +### 6.6 One commit, many markers + +A commit may carry several markers. They are resolved as one set: +the runner checks the permission verdict, then resolves every marker, then substitutes +every value, then dispatches. A failure on any one marker fails the whole call before +anything is sent. `execution-authorization.md` section 3.4 owns the atomic +verify-and-consume rules, which now cover the SET of markers in one commit rather than a +set of operation-level sources. + +## 7. Application and atomicity + +Operations run in array order. Each sees the result of the ones before it. The first +failing operation aborts everything, and no partial result escapes. The caller's base tree +never changes: the engine deep-copies it, and deep-copies every value it writes. + +## 8. What the engine returns ```python @dataclass(frozen=True) @@ -626,81 +563,88 @@ class ChangeSetResult: apply_change_set(base, delta, scope_policy=None, *, validate=None) -> ChangeSetResult ``` -`changed` is the engine's own comparison of its input base against its output. It is NOT -the commit's no-change answer. The commit wrapper compares the canonical persisted form, -which is a different and larger comparison. See `commit-transaction.md` section 5. +`changed` is mandatory before ship, not a convenience. A cornered model commits a no-op to +manufacture success; the usability spike observed it once. `changed=False` lets the wrapper +answer `no_change` and create no revision. The wrapper's own comparison is larger — it +covers the canonical persisted record — and `commit-transaction.md` section 5 owns it. -A `Warning` is structured, never a sentence alone: +A warning is structured: ```json { - "code": "wholesale_list_replace", - "message": "The delta replaced the whole 'tools' list. Use add_item / remove_item.", - "target": ["parameters", "agent", "tools"], - "operation_index": 0 + "code": "text_matched_normalized", + "message": "edits[0].old_text matched after normalizing quotes and dashes.", + "target": ["parameters", "agent", "instructions", "agents_md"], + "operation_index": 1 } ``` -### 7.1 Warning codes - -| Code | When | +| Warning code | When | |---|---| -| `wholesale_list_replace` | A `set` or a legacy `set` replaced a whole `tools`, `skills`, or `mcps` list. | -| `legacy_duplicate_key` | A collection the change set did not touch holds a duplicate key. | +| `text_matched_normalized` | A prose anchor matched only after the normalized retry. Section 5.6.1. | +| `target_normalized` | The wrapper corrected a selector mistake. Section 4.3. | +| `wholesale_list_replace` | A `set` replaced a whole `tools`, `skills`, or `mcps` list. | +| `legacy_duplicate_key` | An untouched collection holds a duplicate key. Section 9. | | `legacy_delta_form` | The delta used the legacy form. | -| `unaddressable_embed` | A touched collection holds an `@ag.embed` entry that no operation can name. | - -## 8. Unique names - -This answers the review's "existing duplicate names" call. The rule protects new -configurations without making old ones uncommittable. +| `unaddressable_embed` | A touched collection holds an `@ag.embed` entry no operation can name. | -Definitions: +## 9. Unique names -- A collection is **item-touched** when an `add_item`, `replace_item`, or `remove_item` - operation names it. +- A collection is **item-touched** when an item operation names it. - A collection is **branch-touched** when a `set`, `merge`, `remove`, or a legacy `set` - writes it or any of its ancestors. A full-data commit branch-touches every collection. + writes it or an ancestor. A full-data commit branch-touches everything. -Rules, checked after every operation, in final validation: +1. An item-touched collection must end with no duplicate key: `duplicate_item_key`. +2. A branch-touched collection must not GAIN a duplicate. A key whose duplicate count rises + is `duplicate_item_key`. A pre-existing duplicate that did not grow only warns. +3. An untouched collection only warns. -1. An item-touched collection must hold no duplicate key. A duplicate is - `duplicate_item_key`. The agent must repair what it edits. -2. A branch-touched collection must not gain a duplicate. The engine compares the base - and the result. A key whose duplicate count rises is `duplicate_item_key`. A duplicate - that already existed and did not grow gives the `legacy_duplicate_key` warning. -3. An untouched collection gives the `legacy_duplicate_key` warning and nothing more. +Rule 2 keeps every existing configuration committable. Open product call 2 may still change +rule 1. -The engine already refuses to act when it addresses a duplicated key inside an operation. -That check stays. It gives `duplicate_item_key` with `match_count`. - -Rule 2 keeps every existing configuration committable. A separate cleanup migration can -repair old duplicates later. - -## 9. The scope policy +## 10. The scope policy ```python ScopePolicy = Callable[[Target], Optional[str]] # a refusal message, or None ``` -The engine checks every operation's target before it applies any operation. A refusal is -a policy answer. It must not depend on how far the change set got. The error names the +The engine checks every operation's target before it applies any operation. A refusal is a +policy answer and must not depend on how far the change set got. The error names the operation index, and the tree stays untouched. -For the legacy form, the engine builds targets: it walks the `set` tree down to the -policy's prefix depth, and it splits each `remove` path on the dot. +For the legacy form the engine builds targets: it walks the `set` tree to the policy's +prefix depth and splits each `remove` path on the dot. + +`read-config.md` section 11 defines the two policies: `PARAMETERS_ONLY` for a run override, +and `AGENT_COMMIT_SCOPE` for a platform-tool commit. A refusal is `out_of_scope`, 422, not +retryable. + +## 11. Platform-kind tool entries are rejected + +An agent's configuration must never contain the playground's injected build kit. Agents +commit those tools by accident today. -Two policies exist. `read-config.md` section 11 defines both. +**The wrapper rejects any `tools` entry with `"type": "platform"`**, with a retryable error +that names the offending entries: -- `PARAMETERS_ONLY` for a run override: the target must sit under `parameters`. -- `AGENT_COMMIT_SCOPE` for a platform-tool commit: it also refuses server-owned fields. +```json +{ + "code": "platform_tool_not_committable", + "message": "These are playground tools, not part of your configuration: commit_revision, test_run.", + "next_step": "Remove those entries from `tools` and send the commit again.", + "entries": ["commit_revision", "test_run"] +} +``` -A refusal is `out_of_scope`, HTTP 422, not retryable. +Rejection beats silent stripping. The usability spike showed that errors teach and silent +corrections do not. Two other guards stand with it: `read_config` reads the STORED +revision, which never contains the injected kit, so reads are clean by construction; and one +line of the tool description says so up front (section 15). -## 10. The error model +## 12. The error model -One failure aborts everything. HTTP 422 for a bad change set. HTTP 409 for a stale base; -see `commit-transaction.md`. +One failure aborts everything. HTTP 422 for a bad change set, 409 for a stale base +(`commit-transaction.md`). ```json { @@ -709,10 +653,11 @@ see `commit-transaction.md`. "message": "No revision was committed.", "operation_index": 1, "operation": "edit_text", - "target": ["parameters", "agent", {"field": "skills", "key": "release-qa"}, "body"], + "target": ["parameters", "agent", {"list": "skills", "key": "release-qa"}, "body"], "reason": { "code": "text_not_unique", - "message": "old_text matched 3 times. Include more surrounding text.", + "message": "old_text matched 3 times.", + "next_step": "Add more surrounding lines to old_text until it appears once, then send the commit again.", "match_count": 3 }, "retryable": true @@ -720,121 +665,237 @@ see `commit-transaction.md`. } ``` -| Reason code | Meaning | Retryable | -|---|---|---| -| `target_not_found` | A segment does not exist. | yes | -| `target_type_mismatch` | A node has the wrong type for the verb. | yes | -| `item_already_exists` | `add_item` found the key. | yes | -| `item_not_found` | `replace_item` / `remove_item` did not find the key. | yes | -| `duplicate_item_key` | Two entries share one key. | yes | -| `text_not_found` | The anchor does not occur. | yes | -| `text_not_unique` | The anchor occurs more than one time. | yes | -| `text_edits_overlap` | Two matches share a character. | yes | -| `text_too_large` | The target string is above the work limit. | no | -| `no_change` | The edits produce identical content. | yes | -| `empty_old_text` | The anchor is empty. | yes | -| `unkeyed_collection` | The list has no key field. | yes | -| `item_key_undefined` | The value has no derivable key. | yes | -| `source_not_found` | The runner could not read the workspace path. | yes | -| `source_invalid` | The source is unusable, or `value_from` reached the engine. | no | -| `source_diff_base_unavailable` | A single-text `set` could not read its old text from `base_revision_id`. Runner-owned. Section 5.1.1. | yes | -| `source_too_large` | The source is above the byte limit. | no | -| `out_of_scope` | The scope policy refuses the target. | no | -| `invalid_delta` | Both forms, no form, or an unknown delta field. | no | -| `invalid_operation` | A shape error. | no | -| `final_validation_failed` | The finished tree is not a valid configuration. | yes | -| `non_embeddable_reference` | The result embeds a static workflow that may not be embedded. | yes | - -`final_validation_failed` carries an `issues` array, so the agent gets every schema -problem at once. - -`non_embeddable_reference` is wrapper-owned, not engine-owned. The commit wrapper raises -it from the existing `_reject_non_embeddable_workflow_embeds` check. It shares this -envelope so the agent learns one error vocabulary. `commit-transaction.md` section 4.1 -defines when it runs. - -## 11. Final validation - -The engine takes a `validate` callable. The callable receives the finished tree. It -returns a list of issues, or it raises. Either way the engine raises one error with -`final_validation_failed`. - -The commit wrapper supplies the validator. It validates the complete revision data -against the workflow schema, and the agent template against `AgentTemplateSchema`. It also -runs the unique-name rules of section 8. - -## 12. Changes the prototype needs - -The prototype is `api/oss/src/core/workflows/change_set.py` in worktree -`agent-a2a2adaa5d154d454`. It implements this contract except for the following points. - -| # | Change | Where | -|---|---|---| -| 1 | Return `ChangeSetResult`, not a bare dict. Compute `changed`. Collect warnings. | `apply_change_set`, `_finish` | -| 2 | Split `VALUE_BEARING`. `set`, `add_item`, and `replace_item` accept `value_from`; `merge` accepts `value` only. The schema must offer the folder source on the item verbs, the file source on `set`, and nothing on `merge`. | `VALUE_BEARING`, `_operation_value` | -| 2b | `set` must not create parents when it carries `value_from`, and its target must already hold a string. Sections 5.1.1 and 5.3. | `_apply_operation` | -| 2c | The folder source carries three policy fields: `on_unsupported`, `on_executable`, and `persist_executable_capability`. `allow_executable_files` is not one of them. Add the constraint check, `persist_executable_capability: true` needs `on_executable: "import"`, as `invalid_operation`. The runner enforces it before any read; the schema states it. Section 5.1.3. | new pydantic source models | -| 3 | Accept and dispatch `match_mode`. Add a matcher table with one entry, `exact`. | `_apply_operation`, `apply_text_edits` | -| 4 | Count occurrences with overlap. Replace `str.count` and `str.index`. | `apply_text_edits` | -| 5 | Create missing plain-string object parents in `set`, under the five rules of 5.3. | `_apply_operation` | -| 6 | Add the work limits of 5.6.2 and the `text_too_large` code. | `apply_text_edits` | -| 7 | Add the unique-name rules of section 8 and the warning codes of 7.1. | new module functions | -| 8 | Add `AGENT_COMMIT_SCOPE`. | scope policies | -| 9 | Add the `maxItems` limits to the schema, and the pydantic operation models with `extra="forbid"`. | new module | - -Everything else in the prototype matches this contract. Its 120 tests stay valid, except -the two that pin non-overlapping counting and the absence of parent creation. - -## 13. Open items - -1. **`match_mode` on the wire today.** The catalog schema will advertise a one-value enum. - A model may read that as noise. We accept the cost, because adding a second mode later - is then not a breaking change. -2. **Rule 1 of section 8.** It asks an agent to repair a duplicate it did not create, - before it can edit that collection. This is a product call. `decisions.md` open call 2 - covers it. -3. **Full-data commits.** They branch-touch everything, so rule 2 applies to them. The - playground saves this way. We must measure how many existing configurations would gain - a warning before we make rule 2 stricter. -4. **The four allowed `set` targets** (section 5.1.1) are the long-text fields we know - today. A later schema can add another one. The list must live in one place, beside the - `item_key` table, so the SDK and the server never disagree about it. -5. **Product call 1, storage normalization.** Gate 2 marks it as blocking engine and - transaction work. It changes exact matching, the stored bytes, and canonical equality. - Answer it before the engine slice starts. -6. **Product call 2, unique-name enforcement.** Gate 2 marks it as blocking engine - validation. It decides which legacy configurations stay committable. Section 8 holds - the recommended rule. - -## 14. Gate 2 resolution - -Gate 2 marked item 1 RESOLVED. Two later points still touch this file. - -| Gate point | Answered in | +### 12.1 Retryable errors + +The agent can fix these and send again. Each one carries `next_step`. + +| Code | Meaning | +|---|---| +| `target_not_found` | A segment does not exist. | +| `target_type_mismatch` | A node has the wrong type for the verb. | +| `invalid_target_shape` | The last segment is the wrong kind for the verb. Section 5.2. | +| `item_already_exists` | `add_item` found the key. | +| `item_not_found` | `replace_item` / `remove_item` did not find the key. | +| `item_rename_not_allowed` | `replace_item`'s value carries a different key. | +| `duplicate_item_key` | Two entries share one key. | +| `item_key_undefined` | The value has no derivable key. | +| `unkeyed_collection` | The list has no key field. | +| `missing_operation_value` | A value-bearing operation carried no `value`. | +| `invalid_operation_shape` | Any other malformed operation the schema also rejects. | +| `text_not_found` | The anchor does not occur. | +| `text_not_unique` | The anchor occurs more than once. | +| `text_edits_overlap` | Two matches share a character. | +| `empty_old_text` | The anchor is empty. | +| `no_change` | The edits produce identical content. | +| `source_not_found` | The runner could not find the file a marker names. | +| `source_unsupported` | The file is not readable as text. | +| `platform_tool_not_committable` | The `tools` list holds a platform-kind entry. Section 11. | +| `non_embeddable_reference` | The result embeds a static workflow that may not be embedded. Wrapper-owned; `commit-transaction.md` section 4.1. | +| `final_validation_failed` | The finished tree is not a valid configuration. Carries `issues`. | + +### 12.2 Non-retryable refusals + +Sending the same payload again never helps. + +| Code | Meaning | +|---|---| +| `out_of_scope` | The scope policy refuses the target. | +| `invalid_delta` | Both forms, no form, or an unknown delta field. | +| `unknown_operation` | An unknown verb or an unknown `match_mode`. | +| `unresolved_file_marker` | An `@ag.file` reached the engine. Section 6.5. | +| `text_too_large` | The target string is above the work limit. | +| `source_too_large` | The file a marker names is above the byte limit. | + +**Why the split matters.** The old model had one `invalid_operation` code marked +non-retryable. An agent honoring `retryable: false` would dead-end on every rename, because +a rename arrived as a shape error. Shape errors an agent can correct are now retryable and +carry the correction; only true refusals are terminal. + +### 12.3 Every retryable error names the next action + +`next_step` is one sentence in the imperative. It is not optional, and it is not a +restatement of the message. + +| Code | `next_step` | |---|---| -| New problem 9. The approval manifest cannot describe `set` plus `value_from` | Section 5.1.1 defines the constrained form the team lead arbitrated on 4 August: `set` takes `value_from` when the source is one file, the target is one of four known long-text fields, and the approval shows a unified diff. Section 5.1.2 records that a folder into `set` stays disallowed, because it has no honest presentation. `workspace-import.md` section 8 gains the single-text-file mode; runner-spike owns that edit. | -| New problem 6. The embed check must survive the transaction | Section 10 adds the `non_embeddable_reference` reason code and marks it wrapper-owned. `commit-transaction.md` section 4.1 owns the behavior. | -| New problem 10. `value_from` conflates import policy with a stored capability | Section 5.1.3 carries the four-layer split the team lead accepted on 4 August. The folder source now holds `on_unsupported`, `on_executable`, and `persist_executable_capability`. The old `allow_executable_files` field is gone from `value_from`; the persisted `SkillTemplate.allow_executable_files` is written only through `persist_executable_capability`. `workspace-import.md` section 5.2 owns the split. | -| Item 1, product calls 1 and 2 can still change the contract | Section 13 items 5 and 6 record both as blocking, with the section each one would change. | +| `revision_conflict` (409) | "Call read_config for the new revision, re-anchor your edits to it, and send the commit again with the new base_revision_id." | +| `item_rename_not_allowed` | "Send remove_item for the old key, then add_item with the new value." | +| `text_not_found` | "Copy old_text from the configuration you read, character for character." | +| `text_not_unique` | "Add more surrounding lines to old_text until it appears once, then send the commit again." | +| `source_not_found` | "Write the file under .agenta-imports/ first, then send the commit again." | +| `platform_tool_not_committable` | "Remove those entries from `tools` and send the commit again." | +| `target_not_found` | "Call read_config for that part of the configuration and correct the target." | + +### 12.4 Enriched content + +Two errors carry the content the agent needs to recover in one turn, instead of forcing an +extra read: + +- **`source_not_found`** lists the folders that DO exist under `.agenta-imports/`, and the + files in the folder the path named if that folder exists. A wrong path is nearly always a + near miss. +- **`text_not_found`** returns the nearest lines of the target string: the three lines with + the highest similarity to `old_text`, each with its line number. The agent then sees + whether its anchor was stale, reformatted, or simply mistyped. -Supporting changes: section 2.1 tables and note, section 5.1 table, sections 5.1.1 to -5.1.3, section 10 reason table, section 12 rows 2, 2b, and 2c, and section 13 item 4. +## 13. Final validation -The founding use case is covered again. US-1 and #5554 are the oversized instruction file. -Section 5.1.1 gives it a path: one workspace file into -`["parameters","agent","instructions","agents_md"]`, approved as a unified diff. +The engine takes a `validate` callable. It receives the finished tree and returns a list of +issues, or raises. Either way the engine raises one `final_validation_failed` carrying an +`issues` array, so the agent gets every problem at once. -## 15. Gate 3 resolution +The wrapper supplies the validator. It validates the revision data against the workflow +schema and the agent template against `AgentTemplateSchema`, and it runs section 9 and +section 11. -Gate 3 marked arbitration ruling 1 "not accepted as written", and finding 2 says why. +## 14. The derived commit message -| Gate point | Answered in | +The server writes the commit message from the operations. The model never sends one. + +The rule: one clause per operation group, joined with "; ", in operation order. + +| Operations | Clause | |---|---| -| Finding 2. The single-text approval can show a diff against the running session while the commit replaces a newer head | Section 5.1.1, condition 3. The old text comes from the revision named by `base_revision_id`, and from nothing else. The worked failure is written out, with the reason the base check cannot catch it: the base is not stale, only the diff is. | -| Ruling 1. If the old value cannot be obtained, fail closed rather than claim a unified-diff approval | Section 5.1.1, condition 3, rule 3. The call refuses with `source_diff_base_unavailable` and reads no workspace bytes. Every earlier fallback is named and forbidden: session memory, the complete new text, and a byte count. | -| The alternative: the API validates an approved old-value digest | Section 5.1.1 records it, states its cost (a new envelope field and a second failure mode), and does not choose it. | +| n `edit_text` on one field | `edited (n edits)` | +| `set` on a field | `set ` | +| `merge` on a field | `updated ` | +| `remove` on a field | `removed ` | +| `add_item` on a list | `added ` | +| `replace_item` | `replaced ` | +| `remove_item` | `removed ` | + +Example: `edited instructions (2 edits); added skill pdf-tools`. + +The legacy form keeps a generic message: `updated configuration`. + +**The ephemeral `description`, when the model sent one, is appended in parentheses**, so the +agent's own words survive without being the source of truth: + +```text +edited instructions (2 edits); added skill pdf-tools (Adding the pdf-tools skill you asked for.) +``` + +`commit-transaction.md` section 5.2 keeps `message` out of the no-change comparison, so a +derived message never causes a revision on its own. + +## 15. The tool description (normative) + +This is the model-facing description of `commit_revision`. It ships as written. It measures +about 1.5 KB and 400 tokens; the 3.2 KB version scored the same (Haiku 55/55, DeepSeek +54/55) and cost 11 to 13 percent more per task. + +Three conditions in section 4.3, section 12.3, and section 12.4 are part of this decision, +not separate: the short document works BECAUSE the wrapper normalizes the repeated-list +mistake, every error names a next step, and the selector key is `list`. -Supporting changes: section 10 adds `source_diff_base_unavailable`, and the cross-reference -now points at `workspace-import.md` section 8.4, the single-text mode. +```text +Commit a change to this agent's own configuration. + +Send `workflow_revision` with `base_revision_id` (the `revision_id` you read) and +`delta`. `delta` holds `operations`; they run in order, and if one fails nothing is +committed. + +TARGET: an array of segments from the configuration root. A string segment names an +object field. An object segment {"list": L, "key": K} names one entry of list L and +stands in place of L's name. Keyed lists: skills, mcps, tools (by name), files (by path). + + ["parameters","agent",{"list":"skills","key":"release-qa"}, + {"list":"files","key":"checklist.md"},"content"] + +OPERATIONS: +- `set` replace one field (needs `value`) +- `merge` deep-merge an object into one field (needs `value`) +- `remove` delete one field +- `edit_text` replace exact substrings in one string field (needs `edits`) +- `add_item` append to a list; target ends with the list name (needs `value`) +- `replace_item` replace one entry; target ends with a selector (needs `value`) +- `remove_item` delete one entry; target ends with a selector -The restricted target set and the single-file rule are unchanged. Gate 3 accepted both. +`edits` is a list of {old_text, new_text}. `old_text` must occur exactly once and match +character for character, line breaks included. Copy it from the configuration you read; +never retype it from memory. + +For a workspace file's content, write {"@ag.file": ""} where the string would go. +Put the file under `.agenta-imports/` first. + + {"operation":"add_item","target":["parameters","agent","skills"], + "value":{"name":"pdf-tools","description":"Make PDFs.", + "body":{"@ag.file":".agenta-imports/pdf-tools/SKILL.md"}}} + +Your `tools` list must not contain the playground's own tools (commit_revision, +test_run, read_config). They are not part of your configuration. +``` + +Two changes from the measured v3: the import root is named `.agenta-imports/`, and the last +paragraph is the build-kit line from section 11. Both are additive and neither changes the +grammar the measurement exercised. + +## 16. Notes for adjacent slices + +**Tool-list changes reopen the session on every harness in v1.** A commit that changes +`tools` does not route to a live catalog update, on any harness. The adapter capability +matrix stays in `adapter-matrix.md` so that flipping one harness to live later is a +one-line capability change, but v1 is uniform: reopen. Nothing in this contract depends on +which route runs; it is recorded here because a reader of the commit path will ask. + +## 17. Changes the prototype needs + +The prototype is `api/oss/src/core/workflows/change_set.py`. + +| # | Change | +|---|---| +| 1 | Return `ChangeSetResult`; compute `changed`; collect warnings. | +| 2 | Rename the selector key `field` to `list`. | +| 3 | Remove all `value_from` handling. Add the `@ag.file` deep scan and `unresolved_file_marker`. | +| 4 | Count occurrences with overlap. | +| 5 | Add `match_mode` with a dispatch table, and the per-class tolerance of 5.6.1. | +| 6 | Create missing plain-string object parents in `set`. | +| 7 | Split the error codes per section 12; add `next_step` to every retryable one. | +| 8 | Add the work limits and `text_too_large`. | +| 9 | Add the unique-name rules and the warning codes. | +| 10 | Add `AGENT_COMMIT_SCOPE`. | + +Wrapper-owned and therefore NOT in the engine slice: the selector normalization (4.3), the +platform-tool rejection (11), the derived message (14), and the enriched error content +(12.4), which needs the workspace and the base revision. + +## 18. Open items + +1. **Whitespace normalization is deliberately narrow** (5.6.1). Only Unicode space + characters fold to ASCII space. Trailing-whitespace trimming and CRLF folding are + excluded because they change length, and a length-changing normalization breaks the + byte-exact write. If prose edits fail often on trailing whitespace, the answer is a + length-preserving pre-check that reports the difference, not a folding match. +2. **Unicode NFC against NFD** is likewise excluded. It is length-changing in the general + case. Decision 1 chose exact storage, so a mixed-form field can only be repaired by + rewriting it whole. +3. **Product call 2, unique-name enforcement**, may still change section 9 rule 1. +4. **Product call 6, storing the authored operations for audit**, would add a field to the + commit record and interacts with section 14. +5. **The four content classes in 5.6.1 are the fields we know today.** A later schema field + needs a class. The classification must live in one place, beside the `item_key` table. + +## 19. Decision history + +What this consolidation changed, and the decision behind each change. + +| Change | Decision | +|---|---| +| `value_from` replaced by the inline `@ag.file` marker, allowed in any string position | Model-usability spike arbitration, 5 Aug. The operation-level source produced the only silent-corruption failure mode; the marker went 91/91. | +| The folder source and the folder-to-skill codec are dropped from v1 | Same. The agent authors skill structure itself. | +| `on_unsupported`, `on_executable`, `persist_executable_capability` all removed; `executable` and `allow_executable_files` are ordinary agent-authored fields the approval card shows | Settled-by-contracts, 5 Aug. One marker is one file, and the all-or-nothing commit makes partial imports impossible. This retires the four-layer split. | +| Import root is `.agenta-imports/`, not `imports/` | Mahmoud, 5 Aug. Dot-folder hidden by shells; the Files drawer already filters the `.agenta-*` prefix. | +| Paths may be relative or absolute inside the workspace | Model-usability arbitration, 5 Aug. Agents write absolute paths naturally. | +| Selector key `field` renamed to `list`; the wrapper normalizes two selector mistakes | Same. The selector caused 62 percent of spike failures. | +| Match tolerance by content class; storage stays exact bytes | Mahmoud's PR review, 5 Aug, plus decision 1 (option A, 5 Aug). | +| `message` leaves the model-facing schema; the server derives it; `description` is appended | Superseded the "optional free text" rule after the v3 measurement, 5 Aug. | +| `invalid_operation` split into retryable shape errors and non-retryable refusals; rename gets its own code | Model-usability arbitration, 5 Aug. An agent honoring `retryable:false` would dead-end on every rename. | +| Every retryable error carries a next step; `source_not_found` and `text_not_found` carry content | Same. | +| Platform-kind tool entries rejected on commit | Mahmoud's PR review, 5 Aug. Rejection over silent stripping, because errors teach. | +| `changed` is mandatory before ship | Same arbitration. A cornered model commits a no-op to manufacture success. | +| The v3 instruction document is folded in as the normative tool description | Same, with the two named edits. | +| Tools-route note: v1 reopens sessions uniformly | Mahmoud's PR review, 5 Aug. | + +Superseded and no longer authoritative: this file's gate-2 and gate-3 resolution sections, +the `value_from` arbitration in `decisions.md` under "Contract phase", and +`workspace-import.md`'s folder-codec and policy-field sections. diff --git a/docs/design/agent-config-editing/contracts/execution-authorization.md b/docs/design/agent-config-editing/contracts/execution-authorization.md index c7ec94a281..a244d38aef 100644 --- a/docs/design/agent-config-editing/contracts/execution-authorization.md +++ b/docs/design/agent-config-editing/contracts/execution-authorization.md @@ -1,8 +1,18 @@ -# Contract: execution authorization for `value_from` +# Contract: execution authorization for workspace file references Status: proposed. This contract answers must-fix item 4 and answer section 2 of `research/design-gate-review-codex.md`. +> **Renamed by the 5 August consolidation.** The workspace source is no longer a `value_from` +> object on an operation. It is an inline `{"@ag.file": ""}` marker that may appear in +> ANY string position of an operation's `value`. `change-set.md` sections 6.1 and 6.6 own the +> new shape. Read every remaining `value_from` in this file as "one `@ag.file` marker", with +> one substitution that matters: **a record is keyed per MARKER, not per operation**, because +> one operation can now carry several. Section 3.4 states the record key and the set rules in +> full. The security argument, the lifecycle, the fail-closed rules, and the limits are +> unchanged; only the unit changed. A full rename pass through this file belongs to +> runner-spike, who owns it. + This contract replaces the tool-call-id cache in `spikes/runner-spike.md`. The cache was not safe. This document defines what replaces it. @@ -50,7 +60,8 @@ memory only. | `manifestDigest` | string | SHA-256 over the approval manifest. Section 4 of `workspace-import.md` defines the manifest. | | `catalogGeneration` | string | The tool-catalog generation that was live when the runner minted the record. | | `sourcePath` | string | The import path the model asked for. It is used for the card and for logs. | -| `operationIndex` | integer | The index of the operation inside `delta.operations`. One record covers one operation. | +| `operationIndex` | integer | The index of the operation inside `delta.operations`. | +| `valuePointer` | string | The JSON Pointer of the `@ag.file` marker inside that operation's `value`, e.g. `/body` or `/files/0/content`. With `operationIndex` it identifies exactly one marker: one record covers one MARKER, not one operation. Section 3.4. | | `createdAtMs` | integer | Mint time. | | `expiresAtMs` | integer | Hard deadline. Section 6 defines it. | | `consumed` | boolean | Single-use flag. Section 3.3 defines the transition. | @@ -180,7 +191,7 @@ The steps run in this order. 3. Write the frozen value into the frozen-value store. Get a handle back. 4. Compute `argsDigest`, `contentDigest`, and `manifestDigest`. 5. Read the live `catalogGeneration`. -6. Store the record, keyed on `toolCallId` plus `operationIndex`. +6. Store the record, keyed on `toolCallId` plus `operationIndex` plus `valuePointer`. 7. Build the approval card from the manifest. A resolution failure stops the call before step 3. The model receives the structured error from @@ -194,7 +205,7 @@ dialog. The check is: -1. Look up the record by `toolCallId` and `operationIndex`. +1. Look up the record by `toolCallId`, `operationIndex`, and `valuePointer`. 2. A missing record fails closed. Section 4 states the one exception. 3. A `consumed` record fails closed. 4. An expired record fails closed. @@ -222,33 +233,53 @@ The runner then substitutes the frozen value into the call body. It replaces `va ### 3.4 Multi-source commits -One commit may carry up to eight `value_from` operations. Sections 3.1 to 3.3 describe one -record. This section defines how the runner handles a set of them. The rule is that the set -behaves as one unit. +**Renamed by the 5 August consolidation.** The source is no longer a `value_from` object on +an operation. It is an inline `{"@ag.file": ""}` marker, and it may appear in ANY +string position of an operation's `value` — so one operation can carry several. See +`change-set.md` sections 6.1 and 6.6. + +Nothing in the logic below changes. Only the unit of the set changes: + +| Was | Is | +|---|---| +| the set of operations carrying a `value_from` | the set of `@ag.file` markers in the whole commit | +| one record per operation | one record per MARKER | +| record key: `toolCallId` + `operationIndex` | record key: `toolCallId` + `operationIndex` + `valuePointer` | + +`valuePointer` is the JSON Pointer of the marker inside that operation's `value`, for +example `/body` or `/files/0/content`. Two markers in one operation are two records, and +the pointer is what keeps them apart. Without it, a set with two markers in one operation +could not be verified member by member, and the "no missing member" check below would pass +with one of the two frozen values substituted. + +One commit may carry up to eight markers (section 6.2 limits). Sections 3.1 to 3.3 describe +one record. This section defines how the runner handles a set of them. The rule is that the +set behaves as one unit. #### 3.4.1 Mint: check the policy before any read -The runner must decide the permission verdict for the whole call BEFORE it reads any folder. +The runner must decide the permission verdict for the whole call BEFORE it reads any file. The order is fixed: -1. Parse the call. Collect every operation that carries a `value_from`. Record the operation - index of each. +1. Parse the call. Walk every operation's `value` and collect every `@ag.file` marker. + Record the operation index and the value pointer of each. 2. Read the permission plan verdict for the call. A `deny` verdict stops here. The runner returns the deny reason. **It performs no workspace read at all.** 3. Check the per-call and per-turn limits in section 6.2 against the collected count. A breach stops here, again before any read. -4. Resolve the sources in operation order. Mint one record per source. +4. Resolve the markers in operation order, then in pointer order within an operation. Mint + one record per marker. -Step 2 matters on its own. A denied call must not touch the filesystem. Reading a folder for a -call that will never run leaks the folder's existence and its content into runner memory, and it +Step 2 matters on its own. A denied call must not touch the filesystem. Reading a file for a +call that will never run leaks the file's existence and its content into runner memory, and it spends the turn's byte budget. Worse, on Daytona it runs a process inside the sandbox for a call the policy already refused. -If any source fails to resolve, the whole mint fails. The runner discards every record it minted -for that call and releases their bytes. It returns the failing operation's index and its -structured error. A commit is one atomic change, so a partially resolvable commit has no useful -meaning. +If any marker fails to resolve, the whole mint fails. The runner discards every record it minted +for that call and releases their bytes. It returns the failing operation's index, the failing +value pointer, and its structured error. A commit is one atomic change, so a partially resolvable +commit has no useful meaning. The records for one call share one `catalogGeneration`, read once at step 4. They share one expiry, computed once. This stops a set from ageing apart. @@ -257,20 +288,20 @@ expiry, computed once. This stops a set from ageing apart. The runner verifies the complete set before it consumes any of it. -1. Determine the required set: every operation index in the executed call that carries a - `value_from`. -2. Look up a record for each required index, keyed by `toolCallId` plus `operationIndex`. +1. Determine the required set: every `{operationIndex, valuePointer}` pair in the executed + call that holds an `@ag.file` marker. +2. Look up a record for each required pair, keyed by `toolCallId` plus that pair. 3. Run every check in section 3.2 against every record. 4. Every record must pass. One failure fails the whole call. Two extra checks apply to the set, and neither is implied by the per-record checks: -- **No missing member.** Every required index must have a record. A call carrying three - `value_from` operations with only two records fails closed. +- **No missing member.** Every required pair must have a record. A call carrying three + markers with only two records fails closed. - **No extra member.** Every record held for this `toolCallId` must correspond to a required - index in the executed call. A record with no matching operation means the executed call is not - the approved call. This catches an attacker who removes an operation from an approved - multi-operation commit to change what the commit does. + pair in the executed call. A record with no matching marker means the executed call is not + the approved call. This catches an attacker who removes an operation, or removes one marker + from an operation, in an approved multi-marker commit to change what the commit does. The `argsDigest` check already covers both cases when it passes, because it binds the whole argument document. These two checks exist so the runner reports the real reason rather than an diff --git a/docs/design/agent-config-editing/contracts/workspace-import.md b/docs/design/agent-config-editing/contracts/workspace-import.md index 04f352212a..15c9f917ab 100644 --- a/docs/design/agent-config-editing/contracts/workspace-import.md +++ b/docs/design/agent-config-editing/contracts/workspace-import.md @@ -1,11 +1,30 @@ # Contract: the workspace import boundary -Status: proposed. This contract answers must-fix item 5 of -`research/design-gate-review-codex.md`, and product calls 5, 6, and the eighth call in its -section 5. - -This contract defines how the runner reads a folder from its workspace and turns it into a -skill value. It replaces the behavior in the `skill-codec.ts` prototype. The prototype was +Status: proposed, and **partly superseded by the 5 August consolidation**. This contract +answers must-fix item 5 of `research/design-gate-review-codex.md`. + +> **What the consolidation changed.** `change-set.md` is authoritative for all of it. +> +> | Was | Is | Where | +> |---|---|---| +> | a `value_from` object on an operation, resolving a FOLDER | an inline `{"@ag.file": ""}` marker resolving ONE FILE, in any string position of a value | `change-set.md` 6.1, 6.2 | +> | the folder-to-skill codec | dropped from v1; the agent authors skill structure itself | `change-set.md` 6.2 | +> | `on_unsupported`, `on_executable`, `persist_executable_capability` | all removed. `executable` and `allow_executable_files` are ordinary agent-authored fields the approval card shows | `change-set.md` 6.2 | +> | the import root `imports/` | **`.agenta-imports/`** | `change-set.md` 6.3 | +> | paths relative to the import root only | relative to the workspace root, or absolute inside the workspace; the runner normalizes both | `change-set.md` 6.3 | +> +> **What still stands, unchanged and still needed:** the designated-root argument (section +> 2.2), path confinement and the descriptor-relative walk (section 3), the symbolic-link +> refusal, the per-file and aggregate caps (section 4.4), the Daytona manifest reader and its +> stated residual race (section 6), the digest rules (section 7), and the approval card and +> its truncation rules (section 8). Read every "folder" in those sections as "the file a +> marker names", and read every "the import" as "one marker's resolution". +> +> Sections 4.2, 4.3, 5.2, and 8.1's `allowExecutableFiles` field are the superseded parts. +> Rewriting them is runner-spike's, who owns this file. + +This contract defines how the runner reads content from its workspace and hands it to a +commit. It replaces the behavior in the `skill-codec.ts` prototype. The prototype was lossy by default and derived policy from filesystem facts. Both are wrong. ## 1. Principles @@ -22,11 +41,11 @@ Four rules drive every decision in this document. ### 2.1 The rule -The runner reads only from a designated import root. The root is `imports/` under the run's +The runner reads only from a designated import root. The root is `.agenta-imports/` under the run's workspace current working directory (`plan.workspace.cwd`). A `value_from.path` is relative to that root. The path `downloaded-skills/pdf-tools` resolves to -`/imports/downloaded-skills/pdf-tools`. +`/.agenta-imports/downloaded-skills/pdf-tools`. The runner refuses any path that resolves outside the root. It refuses before it reads. @@ -41,13 +60,13 @@ agent wrote during the run. A prompt-injected agent can point `value_from` at an human then sees a manifest of file names and sizes. A human approving a skill does not read a manifest as a security boundary. They see a plausible list and they approve. -A designated root moves the control earlier. The agent must first place content in `imports/`. +A designated root moves the control earlier. The agent must first place content in `.agenta-imports/`. That placement is an ordinary file write, which the run's own permission policy already governs. The import boundary then only has to enforce one thing: stay inside the root. ### 2.3 Root behavior -- The runner creates `imports/` during workspace preparation. It creates it empty. +- The runner creates `.agenta-imports/` during workspace preparation. It creates it empty. - The root lives inside the durable workspace, so content placed there survives a warm turn. - The runner never deletes user content from the root. Cleaning it is the agent's job. - An import path that names the root itself is refused. The caller must name one folder. @@ -94,8 +113,8 @@ intermediate directory in the path is still resolved normally, and a symbolic li followed. So an attacker who replaces an intermediate directory between the walk and the open redirects the open outside the import root, and `O_NOFOLLOW` does not fire. -Concretely, the runner walks to `imports/pdf-tools/scripts/` and lists `extract.py`. It then -opens the path `imports/pdf-tools/scripts/extract.py` with `O_NOFOLLOW`. An attacker replaces +Concretely, the runner walks to `.agenta-imports/pdf-tools/scripts/` and lists `extract.py`. It then +opens the path `.agenta-imports/pdf-tools/scripts/extract.py` with `O_NOFOLLOW`. An attacker replaces `scripts` with a symbolic link to `/home/user/.ssh` in between. The open resolves through the link, reaches `/home/user/.ssh/extract.py`, and succeeds. The final component was not a link, so the flag stays silent. @@ -552,7 +571,7 @@ one. What actually bounds the Daytona path: -1. **The import root.** An attacker must first place or modify content under `imports/`. The +1. **The import root.** An attacker must first place or modify content under `.agenta-imports/`. The run's own permission policy governs that write. This is the primary control, and it holds against the confused agent. 2. **The human on the approval card.** The card shows the bytes the runner actually read. A @@ -564,7 +583,7 @@ None of the three stops a well-timed swap by a process that already runs arbitra the sandbox. Against that attacker, on Daytona, this contract does not claim protection. That is an honest position, and it is defensible: an attacker who already runs arbitrary code in -the sandbox can also write whatever it wants directly into `imports/` and let the import read it +the sandbox can also write whatever it wants directly into `.agenta-imports/` and let the import read it legitimately. The race adds little to what that attacker can already do. What the race does add is the ability to defeat the human's review, and the plan must record that as an accepted risk rather than a solved problem. @@ -846,10 +865,10 @@ Every code carries the offending paths, up to 20, and a count of the rest. - Traversal, absolute path, backslash, and NUL are refused before any read. - A symbolic link at the folder root is refused. - A symbolic link inside the folder is refused, even when its target stays inside the workspace. -- A path outside `imports/` but inside the workspace is refused. -- A valid folder BELOW `imports/` is accepted. This is the descendant test in section 6.2, and +- A path outside `.agenta-imports/` but inside the workspace is refused. +- A valid folder BELOW `.agenta-imports/` is accepted. This is the descendant test in section 6.2, and the gate 1 equality test would have failed it. Add it as a regression guard. -- `imports/` itself is refused, per section 2.3. +- `.agenta-imports/` itself is refused, per section 2.3. **TOCTOU, local.** - Replace the FINAL component with a symbolic link between the walk and the open. The open must @@ -1015,9 +1034,9 @@ model-visible catalog schema and repeats that the runner strips the object. | Existing item | Change | |---|---| | `decisions.md` open call 5 | Binary files no longer drop with a warning. They reject by default, with `on_unsupported: "omit"` as the explicit opt-in. | -| `decisions.md` open call 6 | The reach is the designated `imports/` root, not the whole workspace. | +| `decisions.md` open call 6 | The reach is the designated `.agenta-imports/` root, not the whole workspace. | | `spikes/runner-spike.md`, "Codec gaps" | `allow_executable_files` is no longer derived. Binary and oversized files no longer drop silently. Symbolic links are no longer followed. | -| `plan.md` | Add the `imports/` root creation to the workspace slice. Add the Daytona reader as its own unit of work. | +| `plan.md` | Add the `.agenta-imports/` root creation to the workspace slice. Add the Daytona reader as its own unit of work. | ## 13. Gate 2 resolution From 0da1c7693665fb5a14d99d536da1a6772f8f713b Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Wed, 5 Aug 2026 12:20:14 +0200 Subject: [PATCH 22/36] =?UTF-8?q?docs(design):=20decisions=202/4/5/6=20con?= =?UTF-8?q?firmed;=20LF=20stays=20significant=20in=20matching=20(=C2=A718.?= =?UTF-8?q?2=20with=20measured=20corruption=20examples)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../contracts/change-set.md | 43 +++++++++++++++++-- docs/design/agent-config-editing/decisions.md | 13 ++++++ 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/docs/design/agent-config-editing/contracts/change-set.md b/docs/design/agent-config-editing/contracts/change-set.md index 9d85d005a9..bfe5332c2f 100644 --- a/docs/design/agent-config-editing/contracts/change-set.md +++ b/docs/design/agent-config-editing/contracts/change-set.md @@ -866,13 +866,48 @@ platform-tool rejection (11), the derived message (14), and the enriched error c excluded because they change length, and a length-changing normalization breaks the byte-exact write. If prose edits fail often on trailing whitespace, the answer is a length-preserving pre-check that reports the difference, not a folding match. -2. **Unicode NFC against NFD** is likewise excluded. It is length-changing in the general +2. **A bare LF does NOT fold to a space, and the reason is not length.** A single LF to a + single space is one code point to one code point, so it passes the length-preserving + test that excludes CRLF. It is excluded for a different and stronger reason. + + Every other fold is a glyph variant: a smart quote and an ASCII quote are two spellings + of the same character, so the anchor and the stored span hold the same characters and + the model authored the whole span. An LF against a space is a **structural** difference. + The model believes the span is one line, and it writes `new_text` for that belief. The + write then replaces the matched span, so it deletes a line break the model never saw. + + In the prose class that is not hypothetical. `agents_md` and skill bodies are Markdown, + and they routinely hold lists, headings, and fenced code, where a line break is meaning. + Two measured examples, from a model that only wanted to change one token: + + ```text + "Steps:\n- item one\n- item two\n" -> "Steps:\n- item 1 - item two\n" + "```python\nx = 1\ny = 2\n```" -> "```python\nx = 3 y = 2\n```" + ``` + + The first silently merges a two-item list into one. The second silently turns valid + Python into a syntax error. Both are inside a prose-class field, and neither is + reported, because from the matcher's view the anchor matched once. + + So the prose/code split does not contain this risk: prose documents EMBED code and + structure. **The observed failure (spike F.3.6, a stored soft-wrap newline against a + sent space) is handled by the enriched `text_not_found` instead** (12.4): it returns the + nearest lines, the model sees the real break, and it re-anchors. That costs one turn and + risks nothing. + + A narrower rule could keep the win: fold an LF only when it is a soft wrap — not part of + a blank line, not before a Markdown block marker, and not inside a fence. It excludes + the list and heading cases, but not the fenced-code case without fence tracking, and it + puts state into the matcher, which is what this design has kept out. Revisit only if the + enriched error proves insufficient in measurement, and measure the corruption rate, not + only the success rate. +3. **Unicode NFC against NFD** is likewise excluded. It is length-changing in the general case. Decision 1 chose exact storage, so a mixed-form field can only be repaired by rewriting it whole. -3. **Product call 2, unique-name enforcement**, may still change section 9 rule 1. -4. **Product call 6, storing the authored operations for audit**, would add a field to the +4. **Product call 2, unique-name enforcement**, may still change section 9 rule 1. +5. **Product call 6, storing the authored operations for audit**, would add a field to the commit record and interacts with section 14. -5. **The four content classes in 5.6.1 are the fields we know today.** A later schema field +6. **The four content classes in 5.6.1 are the fields we know today.** A later schema field needs a class. The classification must live in one place, beside the `item_key` table. ## 19. Decision history diff --git a/docs/design/agent-config-editing/decisions.md b/docs/design/agent-config-editing/decisions.md index d4bcf272da..01d2f48559 100644 --- a/docs/design/agent-config-editing/decisions.md +++ b/docs/design/agent-config-editing/decisions.md @@ -70,6 +70,19 @@ reasoning is in `spikes/engine-spike.md` (D1-D33, O1-O12) and `spikes/runner-spi never normalized. The prose-side friendliness lives in matching (exact first, then a normalized retry for prose fields only, per the match-tolerance decision above), never in storage. This unblocks slices S1a and S1b. +- **Decision 2 (5 August): confirmed.** A commit may not create a new duplicate + name; a collection the commit touches must end clean; untouched legacy + duplicates warn. The engine already implements the three tiers. +- **Decision 4 (5 August): no.** The agent may not change its own harness.kind. + Human commit only in v1. The write-scope allow-list stays fail-closed. +- **Decision 5 (5 August): no.** Agent commits stay scoped to parameters.agent. +- **Decision 6 (5 August): yes, store the operations for audit.** Storage: one + nullable JSONB column on the workflow revision row, beside the existing message + and author fields, holding the operations as authored EXCEPT that every + @ag.file marker is replaced by a stub {path, size, digest}, never the bytes. + No backfill; old revisions simply have no operations record. S1b implements it. +- **Decision 3: reformulated after Mahmoud's question; awaiting his pick.** See + the open-calls section. ## Arbitrations after gate 2 (team lead, 4 August) From 4b61d427ff3417d4bc351ea6c0cf3baaf5b938dc Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Wed, 5 Aug 2026 12:31:58 +0200 Subject: [PATCH 23/36] docs(design): decision 6 amended (no migration; audit via existing message/description); dao lock conditions --- docs/design/agent-config-editing/decisions.md | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/docs/design/agent-config-editing/decisions.md b/docs/design/agent-config-editing/decisions.md index 01d2f48559..1b983c81fd 100644 --- a/docs/design/agent-config-editing/decisions.md +++ b/docs/design/agent-config-editing/decisions.md @@ -76,11 +76,21 @@ reasoning is in `spikes/engine-spike.md` (D1-D33, O1-O12) and `spikes/runner-spi - **Decision 4 (5 August): no.** The agent may not change its own harness.kind. Human commit only in v1. The write-scope allow-list stays fail-closed. - **Decision 5 (5 August): no.** Agent commits stay scoped to parameters.agent. -- **Decision 6 (5 August): yes, store the operations for audit.** Storage: one - nullable JSONB column on the workflow revision row, beside the existing message - and author fields, holding the operations as authored EXCEPT that every - @ag.file marker is replaced by a stub {path, size, digest}, never the bytes. - No backfill; old revisions simply have no operations record. S1b implements it. +- **Decision 6 (5 August, amended same day): audit through the EXISTING fields, + no new column, no migration.** Mahmoud rejected the JSONB column. The + server-derived commit message (already decided) IS the audit: it is built from + the operations, so it is always accurate ("edited instructions, 2 edits; added + skill pdf-tools from .agenta-imports/pdf-tools"). Where more detail helps, the + commit record's existing description field carries it, still text, still no + schema change. A machine-readable operations store is dropped from v1 and noted + in open-issues as possible future work. +- **The dao lock extension (2a) is accepted with three conditions (Mahmoud, + 5 August):** (1) it ships as its OWN minimal stacked PR containing only the + lock condition change and the two-writer race test, sized for review by Mahmoud + and the CTO; (2) before implementation, verify which other flows commit through + the same dao path and show they are unaffected; (3) document why the lock beats + the alternative (a database unique constraint on parent linkage would also + serialize but needs a migration, which is excluded). - **Decision 3: reformulated after Mahmoud's question; awaiting his pick.** See the open-calls section. From 17e9639838135a0dd07b58c021d94d5711cc90c2 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Wed, 5 Aug 2026 13:06:16 +0200 Subject: [PATCH 24/36] docs(design): all six product calls closed; dao lock impact note landed --- docs/design/agent-config-editing/decisions.md | 6 +- .../notes/dao-lock-impact.md | 185 ++++++++++++++++++ 2 files changed, 189 insertions(+), 2 deletions(-) create mode 100644 docs/design/agent-config-editing/notes/dao-lock-impact.md diff --git a/docs/design/agent-config-editing/decisions.md b/docs/design/agent-config-editing/decisions.md index 1b983c81fd..c0a3486ba1 100644 --- a/docs/design/agent-config-editing/decisions.md +++ b/docs/design/agent-config-editing/decisions.md @@ -228,9 +228,11 @@ reasoning is in `spikes/engine-spike.md` (D1-D33, O1-O12) and `spikes/runner-spi - Embedded skills stay unaddressable in v1; support can be added later without a breaking change. (Was open call 3.) -## Open product calls (waiting on Mahmoud) +## Open product calls -Six distinct decisions. The first five block their implementation slices. +ALL SIX ARE NOW DECIDED; see 'Product calls confirmed by Mahmoud' above. +Decision 3 closed by default (respect today's permission policy) per Mahmoud's +handoff on 5 August. The original option text stays below for the record. 1. **Storage normalization** (blocks S1a/S1b). Normalize configuration strings once on write (Unicode NFC, line endings LF), or preserve exact bytes? The second gate diff --git a/docs/design/agent-config-editing/notes/dao-lock-impact.md b/docs/design/agent-config-editing/notes/dao-lock-impact.md new file mode 100644 index 0000000000..cb4c5bbfc1 --- /dev/null +++ b/docs/design/agent-config-editing/notes/dao-lock-impact.md @@ -0,0 +1,185 @@ +# The variant lock on `commit_revision`: impact and alternatives + +For the review of the S1b-lock lane, by Mahmoud and the CTO. Written 5 August 2026. + +This note answers the three conditions attached to the lock decision: which other flows +commit through the same DAO path and why each is unaffected, why the lock beats a unique +constraint, and what the lane actually contains. + +## 1. What the lane changes + +`GitDAO.commit_revision` (`api/oss/src/dbs/postgres/git/dao.py:1565`) gains one optional +parameter, `expected_head_revision_id`. When a caller passes it, the DAO: + +1. locks the variant row with `SELECT ... FOR UPDATE`, +2. **re-reads the head revision id under that lock**, +3. raises `RevisionConflict` when it differs from the caller's expectation, +4. otherwise inserts as before. + +The lock condition becomes `initial or expected_head_revision_id is not None`. A caller +that passes neither takes no lock and behaves exactly as it does today. + +### 1.1 Why the re-read is part of this lane, and not optional + +The scoping said "only the lock-condition change". A lock alone does not give the +invariant, so the lane would not be reviewable as a safety change without the re-read. + +The base comparison lives in the service today, before the DAO is called. With only a +lock added, two writers still both succeed: + +```text +A: reads head N (its own transaction) -> base check passes -> DAO: lock, insert, commit +B: reads head N (its own transaction) -> base check passes -> DAO: lock (waits), insert +``` + +The lock serialized the inserts. It did not catch the stale base, because B checked before +it held the lock. Only a read taken **while holding the lock** can see A's insert. That is +three added lines inside the same locked block, and it is what makes the lock mean +anything. + +The service keeps its own pre-check as a cheap early-out: it fails the common case one +round trip earlier and produces the same 409. + +## 2. Every flow that commits through this DAO + +`GitDAO` is never subclassed, and `commit_revision` has exactly six production callers. +The git layer is shared by **four** domains, each binding its own DBE triplet. + +| Domain | Commits at | Passes the new parameter? | +|---|---|---| +| workflows | `core/workflows/service.py:1953` | Only from the checked commit path (S1b-main). | +| environments | `core/environments/service.py:1106` | No. | +| testsets | `core/testsets/service.py:1039` | No. | +| queries | `core/queries/service.py:976` | No. | + +`applications`, `evaluators`, and `prompts` are **not** separate git domains. They are +façades over `WorkflowsService`, so they reach the same workflows call site and are covered +by that row. + +The two remaining callers are the DAO's own `fork_variant` +(`dao.py:969` and `:994`), which replays revisions into a new variant in a loop. + +### 2.1 Why each is unaffected + +Every one of them is unaffected for the same structural reason: **the new behavior is +opt-in at the call site, and nothing but the workflows checked-commit path opts in.** The +parameter defaults to `None`, the lock condition is unchanged when it is `None`, and no +existing caller was edited. + +Flow by flow: + +- **environments, testsets, queries.** They never pass `expected_head_revision_id`. Their + commits take no lock, exactly as today. Their `initial=True` calls keep the lock they + already had, with the same guard. +- **applications and evaluators.** They call `commit_workflow_revision`, which is the + unchecked entry point. S1b-main routes only the workflow commit ENDPOINT through + `commit_workflow_revision_checked`; these façades keep calling the original method, + which passes nothing new. +- **`fork_variant`.** The loop is the flow most exposed to a new lock, because it commits N + revisions in sequence. It passes neither `initial` nor an expectation, so it takes no + lock and its cost is unchanged. Had the lock been made unconditional, this loop would + have taken and released the same row lock once per replayed revision. +- **Data migrations.** Five OSS and five EE data migrations construct a `GitDAO` and commit + through the service methods in long loops + (`api/oss/databases/postgres/migrations/core/data_migrations/`). None passes an + expectation, so no migration takes a new lock. This matters: an unconditional lock would + have been taken thousands of times during a migration run. +- **Direct inserts that bypass `commit_revision` entirely.** `GitDAO.create_revision` + (`dao.py:1008`) is a separate insert path used by all four domains, and three migrations + insert revision rows directly (including one raw `INSERT INTO workflow_revisions`). These + are unaffected because they are untouched — but see section 5, because they are also + outside the invariant. + +### 2.2 The one shared cost + +Two concurrent CHECKED commits on the same variant now serialize. That is the intended +behavior and the only new contention. Two commits on different variants never meet: the +lock is one row, scoped by project and variant. + +## 3. Why the lock, and not a unique constraint + +A unique constraint on parent linkage (a `(variant_id, parent_revision_id)` uniqueness, or +a uniqueness on the version sequence) would also serialize writers: the second inserter +would fail the constraint and could be mapped to 409. + +It is rejected for one decisive reason and two supporting ones: + +1. **It needs a migration, and migrations are excluded.** The revision tables have no + parent-linkage column today, so the constraint needs both a schema change and a backfill + over every existing revision in every one of the four domains. Decision 6 already + excluded schema migrations from this work. +2. **It would constrain all four domains at once.** A table constraint is not opt-in. The + moment it exists, environments, testsets, queries, `fork_variant`, and every data + migration are subject to it, and any of them that legitimately produces two revisions + with the same parent starts failing. The lock is a per-call parameter, so its blast + radius is exactly the callers that ask for it. +3. **It reports the wrong thing.** A constraint violation says "this row already exists", + not "the head moved to N+1". The caller needs the current head id to retry in one step + (`contracts/commit-transaction.md` 6.1). Recovering it after a violation means another + read anyway. + +An advisory lock (`pg_advisory_xact_lock`) was also considered. It avoids touching the +variant row, but it is a second locking scheme to reason about, and the variant row lock +already exists in this method for the `initial` guard. Reusing it keeps one mechanism. + +## 4. The sharp edge the reviewers should know about + +`AsyncEngine.session()` returns an `async_scoped_session` keyed on the current asyncio task +(`api/oss/src/dbs/postgres/shared/engine.py:47-50`). **Within one task, a nested +`async with engine.session()` yields the SAME session, not a savepoint.** The inner block's +`await session.commit()` commits the outer work, and its `await session.close()` closes the +shared session. + +Consequences for this lane: + +- The lock is held from the `SELECT ... FOR UPDATE` to the explicit `await session.commit()` + inside `commit_revision`. The insert happens inside that window, so the guard and the + insert are atomic. This is true today for the `initial` guard and stays true for the new + one. +- The post-insert helpers (`_get_version`, `_set_version`, `_null_revision_fields`) open + what looks like a nested session and therefore run AFTER the lock is released. They are + bookkeeping on the row just inserted, not part of the invariant, so this is acceptable — + but it is not obvious from reading the code, and anyone extending this method should know + it before they move work around. +- It also means the invariant cannot be widened to span the service's head read by simply + wrapping the call in a session: the service's read and the DAO's insert would share one + session and one transaction, which changes the failure semantics of every other caller. + Moving the whole apply step inside the DAO transaction, as + `contracts/commit-transaction.md` section 3.1 describes, remains the way to close the + remaining window. + +## 5. What this lane does NOT give + +Stated plainly, so the review does not over-read it: + +- **The service-side apply is still outside the lock.** S1b-main reads the head, applies + the change set, then calls the DAO. The DAO's re-read closes the window between the + service's check and the insert, which is the window that matters for two concurrent + writers. It does not make the read-apply-insert sequence one transaction. The full + invariant needs the `build` callback of `commit-transaction.md` section 3.1. +- **`create_revision` is not covered.** It inserts revisions on a separate path with no + guard at all, including the `initial` one. Nothing in this lane changes that. If the + "one initial revision" invariant matters, that path is a hole today, independent of this + work. +- **Nothing protects against a writer that passes no expectation.** A legacy commit still + wins last-write. That is deliberate: shipped playbooks omit the base id, and refusing + them would break them. + +## 6. Tests + +`api/oss/tests/pytest/unit/git/test_commit_revision_lock.py` (10 tests) pins: + +- an unchecked commit takes no lock (every existing caller's behavior), +- an initial commit still takes the lock, and the lock precedes its count guard, +- a checked commit takes the lock, and the head read happens under it, not before, +- a moved head refuses, inserts nothing, and reports both ids, +- a matching head commits, +- an empty variant accepts any expectation (the first checked commit must be possible), +- neither conflict is swallowed by `suppress_exceptions`, +- the two-writer sequence: the winner inserts, the loser's re-read sees the new head and + refuses. + +They drive the DAO's ordering with a fake session, so they run in the unit suite with no +database. **A two-writer test against real Postgres, with two genuine connections, belongs +in the integration suite** and is not in this lane: the unit tests prove the ordering and +the refusal, but only a real database proves the lock actually blocks. From 27384d45fe0f92c0639f8b99785f06d56314b851 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Wed, 5 Aug 2026 14:09:08 +0200 Subject: [PATCH 25/36] docs(design): import contract aligned: /proc/self/fd walk documented, absolute-path row struck, stale budget notes removed --- .../contracts/workspace-import.md | 67 ++++++++++++++----- 1 file changed, 52 insertions(+), 15 deletions(-) diff --git a/docs/design/agent-config-editing/contracts/workspace-import.md b/docs/design/agent-config-editing/contracts/workspace-import.md index 15c9f917ab..1b203d8cac 100644 --- a/docs/design/agent-config-editing/contracts/workspace-import.md +++ b/docs/design/agent-config-editing/contracts/workspace-import.md @@ -83,11 +83,18 @@ The runner performs a lexical check and a real-path check. Neither is sufficient The lexical check rejects, before any filesystem access: -- an absolute path; -- any `..` segment; +- any `..` segment, tested on the RAW segments (normalizing first would collapse + `a/../../b` into an escape that no longer looks like one); - a backslash separator; - a NUL byte; -- a path longer than 1024 bytes. +- a path longer than 1024 bytes; +- an absolute path that resolves OUTSIDE the workspace. + +An absolute path inside the workspace is normalized, not refused. `change-set.md` section +6.3 is authoritative for the accepted path forms: relative to the import root, relative to +the workspace root, or absolute inside the workspace. An earlier version of this list +refused every absolute path; that predates the ruling, which is that agents write absolute +paths naturally and refusing them fights the model for nothing. The real-path check resolves every symbolic link and compares the result to the resolved root. The resolved target must be the root or must live under it. @@ -124,9 +131,9 @@ and never re-resolves from the root. 1. Open the import root once. Verify it with `fstat` on the handle. 2. For each directory level, open the child **relative to the parent's descriptor**, with the - no-follow and directory flags set. In Node this is `fs.opendir` on a handle plus `openat` - semantics through `fs.promises.open` with a `dir` handle where the runtime exposes it, or a - small native helper where it does not. + no-follow and directory flags set. In Node this is + `fs.open("/proc/self/fd//", ...)`, one component at a time. See the note + below. 3. Open each file relative to its parent directory's descriptor, with `O_NOFOLLOW`. 4. `fstat` every handle. Read the type, the size, and the mode from the handle, never from a path. @@ -137,13 +144,37 @@ This removes the class. A descriptor names an inode, not a path. Replacing a dir tree after the runner holds its descriptor does not move the descriptor. The attacker can only change what a **new** path lookup would find, and the runner performs none. -Two implementation notes for the plan. +**How it is built, and it needs no native helper.** Node's public API exposes no `openat`, +and `fs.promises.opendir` returns a `Dir` with no usable descriptor for relative opens. An +earlier version of this section concluded that the walk therefore needed a narrow native +helper or a documented fallback, and asked the plan to budget it. That was wrong, and the +budget line is withdrawn. + +`/proc/self/fd/` names the directory the descriptor points at. So opening +`/proc/self/fd//` resolves `` against the INODE the runner already +holds, not against a path that may have been replaced since. Walking one component at a +time through that, with `O_NOFOLLOW` on each open, gives exactly the property this section +requires, in plain Node: + +```ts +fs.open(`/proc/self/fd/${parent.fd}/${name}`, O_RDONLY | O_NOFOLLOW) +``` -- Node's public API does not expose `openat` directly. `fs.promises.opendir` returns a `Dir` with - no usable descriptor for relative opens on every platform. So this needs either a narrow native - helper, or a documented fallback. It is real work, not a flag change. The plan must budget it. -- If the fallback is used, the contract's threat model changes. State it, do not hide it. See - section 3.4. +Two conditions come with it, and both hold here. The component must be a single name, never +a multi-component path, or the lookup an attacker can redirect comes back. And `/proc` must +be mounted, which makes this Linux-only — fine, because the runner runs in Docker and in +Daytona, both Linux. + +Implemented in `services/runner/src/tools/workspace-reader.ts` and verified the honest way: +a probe ran the rejected path-based alternative against the same fixture and read a file +from OUTSIDE the import root through a swapped intermediate directory, exactly as this +section predicts, while the descriptor walk refuses it. + +One error-reporting note. With `O_DIRECTORY` and `O_NOFOLLOW` together, Linux reports a +symbolic link as `ENOTDIR` on some kernels and `ELOOP` on others. Both refuse the component, +so the security property is identical; the reader re-opens once without `O_DIRECTORY`, +still relative to the same descriptor, purely to tell "symbolic link" from "not a +directory" in the message. **Daytona runs.** Section 6 defines the manifest. The window there is wider, and section 6.5 states plainly that it is not closed. @@ -169,7 +200,12 @@ Section 6.5 says so. ### 3.4 The descriptor walk is required for v1 **Decided in answer to gate 3, finding 3.** The descriptor-relative walk in section 3.2 is -REQUIRED. The path-based fallback is a rejected alternative. The plan must budget the work. +REQUIRED. The path-based fallback is a rejected alternative. + +**Built and landed, at no extra cost.** The `/proc/self/fd` technique in section 3.2 gives +the required property in plain Node, so the walk needed no native helper and the plan needs +no budget line for one. The rest of this section stands: the fallback is still rejected, and +the conditions below still apply if anyone reverses that. The gate 1 and gate 2 versions of this section left the choice open, "if the plan decides the native helper is not worth its cost". Gate 3 is right that this is not a choice a plan can defer: @@ -1050,8 +1086,9 @@ model-visible catalog schema and repeats that the runner strips the object. Not resolved here, by design: -- Gate 2 item 7, the slice plan, belongs to `plan.md`. §2.3, §3.2, and §6 each name work the - plan must budget. +- Gate 2 item 7, the slice plan, belongs to `plan.md`. §2.3 and §6 each name work the plan + must budget. §3.2's native-helper budget is withdrawn: the `/proc/self/fd` walk needs no + helper and is already built. ## 14. Gate 3 resolution From 7c4be6b1edcd7ebac921822622fbe8694b45e86d Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Wed, 5 Aug 2026 14:18:51 +0200 Subject: [PATCH 26/36] docs(design): replayability condition recorded in the adapter matrix; s7e credential handoff brief --- .../contracts/adapter-matrix.md | 35 ++++++ .../notes/s7e-credential-handoff.md | 113 ++++++++++++++++++ 2 files changed, 148 insertions(+) create mode 100644 docs/design/agent-config-editing/notes/s7e-credential-handoff.md diff --git a/docs/design/agent-config-editing/contracts/adapter-matrix.md b/docs/design/agent-config-editing/contracts/adapter-matrix.md index 1215048d46..0e69b6d666 100644 --- a/docs/design/agent-config-editing/contracts/adapter-matrix.md +++ b/docs/design/agent-config-editing/contracts/adapter-matrix.md @@ -529,6 +529,41 @@ Two options, in order of preference: A reopen that cannot verify history must not report continuity to the user. Silent history loss is worse than a slower turn. +#### 6.2.1 What was implemented: option 2, as a REPLAYABILITY condition + +**Decided during lifecycle step 6 (lane s7d), and this is the rule the code follows.** + +Option 1 is unavailable. The ACP surface exposes no conversation length and no last-message +identifier, so there is no primitive to read back. A check written against nothing would pass +always and prove nothing, which is worse than having no check: it would let a reopen claim +continuity on evidence that does not exist. + +So the runner implements option 2, and the condition it tests is REPLAYABILITY rather than +verification: + +| The request | Native history | The reopen | +|---|---|---| +| Carries the full transcript | Not load-bearing. The turn replays it, so a session that lost its memory is indistinguishable from one that kept it. | **Allowed.** | +| Carries only the last message | IS the conversation. Losing it silently truncates the user's context, and nothing can prove a reload restored it. | **Refused.** The caller rebuilds. | + +The refusal happens BEFORE the session is closed. A rebuild therefore starts from a live session +rather than from one the runner already tore down. + +`services/runner/src/environment/harness-session-lifecycle.ts` `reopen` implements this, and the +caller supplies the condition from `carriesMinimalHistory(request)`. + +WHY THIS AND NOT A STRICTER RULE. Refusing every unverifiable reopen would refuse every reopen, +because none is verifiable. The route would be permanently inert, and the uniform tool, MCP, +prompt and harness-file changes would all keep costing a full sandbox rebuild. The replayability +condition keeps the route useful for the common case and fails closed for exactly the case where +history actually matters. + +WHEN OPTION 1 BECOMES AVAILABLE. If an adapter ever exposes a conversation length or a +last-message identifier, option 1 is still the preferred rule and it SUPERSEDES this section for +that adapter: verify positively, then allow a reopen for a last-message-only request once the +verification passes. Until then this section is the implemented contract, and option 1 is the +documented fallback rather than the other way round. + This obligation is not Codex-specific. It applies to every reopen. It is written here because Codex is the harness whose only route is reopen. diff --git a/docs/design/agent-config-editing/notes/s7e-credential-handoff.md b/docs/design/agent-config-editing/notes/s7e-credential-handoff.md new file mode 100644 index 0000000000..0930c1bcfa --- /dev/null +++ b/docs/design/agent-config-editing/notes/s7e-credential-handoff.md @@ -0,0 +1,113 @@ +# Handoff brief: lane s7e (credential epoch, credential delivery, Daytona identity split) + +Written by the runner-spike agent that completed lanes s5 through s7d, at the end of a long +session, for a successor taking the credential work fresh. The coordinator offered the handoff +rather than push the most security-sensitive remaining work through a degraded agent. That was +the right call and this brief exists to make the swap cheap. + +## What is already true + +Read these first. They are the contract, not background: + +- `contracts/adapter-matrix.md` — the reconciliation contract. Section 1.4 (exempt classes), + section 2.4 (the canonical generation payload and what is EXCLUDED from it), section 4.3 + (untrusted acknowledgement), section 6.2 + 6.2.1 (reopen and the replayability condition). +- `contracts/execution-authorization.md` §2.3.3 — `strictCanonicalJson`, the exact serializer. +- `research/runner-lifecycle-codex.md` steps 8 and 9 — the work this lane implements. +- `src/lifecycle/reconciliation-router.ts` — the KNOWN DISAGREEMENTS block near the bottom is the + single most relevant paragraph in the codebase for this lane. Read it before anything else. + +Landed lanes: s5 (applied state, teardown reasons), s6 (coordinator extraction, shadow routing), +s7a/s7b (environment split into five units), s7c (two live routes authoritative), s7d (reopen + +the inventory refutation). + +## The three pieces, and why they are not one piece + +The lane is described as "item 2 + item 3" but it is really three jobs: + +### 2a. Feed the credential epoch into the desired/applied comparison + +CONTAINED. The router currently cannot see a credential rotation at all, because credential +VALUES are deliberately excluded from every facet digest — digests are logged, and a digest over +a small field space is guessable. Rotation is tracked separately by `CredentialEpoch` +(`session-identity.ts`), a timing-safe comparison the router never consults. + +Consequence today: `mismatch:credentials-rotated` is the LAST counted disagreement in the shadow +counters, and there is a test in `lifecycle-live-routes.test.ts` named "KNOWN GAP, still counted" +that asserts it stays visible. That test is deliberate. When you close the gap, that test must be +rewritten, not deleted — it is the record. + +The shape question: the epoch is not a digest and must not become one. It belongs in the plan as +its own input producing a `restart-runtime` action, not as a ninth facet whose digest gets logged. + +### 2b. Credential refresh DELIVERY + +THIS IS THE HARD ONE, AND IT DOES NOT EXIST TODAY. Mahmoud's Q5 requirement is that a rotated +Daytona model key restarts at most the daemon, never the sandbox. Today Daytona secrets are a +CREATE-TIME concept: `provider.ts` hashes the full create request plus the secret plan into a +create fingerprint, and `daytona-secret-provider.ts` DESTROYS the sandbox when it differs. + +So this needs a new provider-port operation for injecting credentials into an already-created +sandbox. The coordinator asked for the PORT SHAPE first, sent for review before any code moves, +because it carries live secrets. Do that. The `AcquireContext` precedent (lane s7b) is the model: +publish the type alone, get it reviewed, then move code. That review caught five real defects. + +Things I would put in that design and would want a reviewer to check: + +- Where the secret is in memory, for how long, and what clears it. `AcquireContext` deliberately + has no raw-secret accessor; do not add one. +- Whether delivery is push (runner writes into the sandbox) or pull (daemon fetches with a + short-lived grant). Push means the secret crosses the daemon API; pull means a grant that is + itself a credential. +- What happens to the OLD credential. A refresh that installs the new one without invalidating + the old leaves both live, which is worse than a rebuild. +- Failure semantics. A half-delivered credential must fail closed to a rebuild, never leave the + daemon with a partially updated environment. The `applyReconcilePlan` contract already says + applied state advances only on full success; keep that. +- Logging. No secret, no digest of a secret, no length. The shadow logger's rule (facet names and + action kinds only) applies here too. + +### 3. The Daytona creation-identity split + +Depends on 2b, because it is what lets a mutable credential change reconcile instead of rebuild. +`research/runner-lifecycle-codex.md` step 9 has the target shape: a `SandboxGenerationId` covering +provider/image/target/immutable topology that rebuilds, versus mutable state that reconciles on +reconnect and FAILS CLOSED when reconciliation fails. + +## Traps I hit, so you do not + +1. **Facet granularity is a safety property.** In s7c I nearly shipped two silent security + downgrades because one facet mixed harness files with instructions and another mixed + permissions with the model. `adapter-matrix.md` §4.3.2 rule 3 and §1.4 forbid both by name. + When in doubt, split: an over-fine facet costs a rebuild, an over-coarse one downgrades a + security-relevant change silently. There is a counting scope-guard test that stops a new live + route appearing by accident — keep it honest rather than widening it. + +2. **The shadow must describe the plan it ACTED ON.** I logged after the apply committed, so the + counter recorded `no-op` and could never name the route. `ShadowLogInput.plan` exists for this. + +3. **Do not invent a verification that verifies nothing.** The reopen work nearly grew a native- + history check against an ACP surface that exposes no such primitive. The replayability + condition (§6.2.1) is the honest substitute. The same instinct applies to credential delivery: + if you cannot confirm the daemon actually took the new secret, say so and rebuild. + +4. **`environment.ts` is a composer now.** The five units live in `src/environment/`. New + lifecycle behavior goes in a unit, not back into the composer. Seam tests in + `environment-units.test.ts` assert the composer delegates and does not inline. + +5. **Applied state advances ONLY on success.** This is the invariant s5 made structurally + impossible to violate for request-derived fingerprints. `apply-plan.ts` keeps `commitApplied` + as the last statement, unreachable from any failure path. Credentials must not reintroduce it. + +## The test that tells you when 2a is done + +`lifecycle-live-routes.test.ts` → "KNOWN GAP, still counted: a rotated credential remains a +disagreement". When the epoch reaches the router, that test flips: a rotation should produce an +AGREEMENT on `restart-runtime`, and the total disagreement count across the suite should be zero. +That is the completion signal for the whole shadow-routing arc. + +## State of the tree at handoff + +108 test files / 1729 tests green, `tsc --noEmit` clean, on top of the landed s7d lane. Nothing in +progress, nothing half-migrated. The only uncommitted change is the `adapter-matrix.md` §6.2.1 doc +edit recording the replayability rule. From 24f1e99aebc1f8ea1ed301fc973416c96df938e3 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Wed, 5 Aug 2026 15:08:40 +0200 Subject: [PATCH 27/36] =?UTF-8?q?docs(design):=20=C2=A74=20gating=20text?= =?UTF-8?q?=20replaced=20with=20the=20decision=20as=20taken?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../contracts/execution-authorization.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/design/agent-config-editing/contracts/execution-authorization.md b/docs/design/agent-config-editing/contracts/execution-authorization.md index a244d38aef..bcfbae9128 100644 --- a/docs/design/agent-config-editing/contracts/execution-authorization.md +++ b/docs/design/agent-config-editing/contracts/execution-authorization.md @@ -377,12 +377,14 @@ Three further rules apply to the inline path. step. This keeps one execution path and one set of digests. 3. The inline path must apply the same limits as the gated path. See section 6. -Open product call. Item 4 in `decisions.md` recommends that the runner follows the run's policy -and forces no gate. The gate review recommends the opposite for v1: force a gate, because tool -permission and workspace-read permission are different policies. This contract implements the -narrower behavior the coordinator specified, which allows an ungated path behind an explicit -`allow`. If Mahmoud accepts the reviewer's call, delete section 4's exception and make every -`value_from` operation force a gate. Nothing else in this contract changes. +**Decided, 5 August.** `decisions.md` open call 3 is closed: gate by default, with inline +resolution only on an explicit `allow` verdict from the permission plan. That is exactly the +behavior this section already specifies, so nothing here changes and the exception stays. + +The alternative the gate review preferred — force a gate on every import, because tool +permission and workspace-read permission are different policies — was not taken. If it is +ever revisited, the change is to delete this section's exception and make every marker force +a gate; nothing else in this contract moves. ## 5. Where the frozen bytes live From 17170648ad74062493e4deadc9569f0d82af5f46 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Wed, 5 Aug 2026 15:28:26 +0200 Subject: [PATCH 28/36] docs(design): rotation ruling: option 2, rotate in place with propagation-honest semantics --- docs/design/agent-config-editing/decisions.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/design/agent-config-editing/decisions.md b/docs/design/agent-config-editing/decisions.md index c0a3486ba1..0f54a9b3fd 100644 --- a/docs/design/agent-config-editing/decisions.md +++ b/docs/design/agent-config-editing/decisions.md @@ -132,6 +132,23 @@ reasoning is in `spikes/engine-spike.md` (D1-D33, O1-O12) and `spikes/runner-spi is recorded in the backlog with its insertion points named, so enabling one harness later is a capability flip plus the shelved component, not a redesign. +## Rotation ruling (Mahmoud, 5 August: option 2, rotate in place) + +- Context correction that decided it: rotating a key in Agenta revokes nothing; + the old key stays valid at the provider until its owner revokes it there. So + the propagation window (seconds during which running sandboxes still use the + old value) changes real exposure by approximately nothing, and the sandbox + never possesses the raw key anyway (placeholder plus egress substitution). + Killing a distrusted sandbox is a different, existing action. +- Shipped behavior: value-only rotation applies live; no restart, no rebuild. + The turn holds until the stated propagation bound before acting on the new + value. Docs say plainly: the new value applies to sandbox traffic within + seconds; to fully kill a compromised key, revoke it at the provider. +- What survives from the security review: never claim invalidation before + propagation completes, and providers with no propagation signal stay on the + rebuild route. Daytona declares a bounded propagation; the eligibility stays + a capability value. + ## Decisions from Mahmoud's PR review (5 August) - **Tool-list changes route to session reopen on EVERY harness in v1.** Uniform From 8e87db1a5f81f3878cbc4e6fa51c4843f322ef1b Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Wed, 5 Aug 2026 15:56:20 +0200 Subject: [PATCH 29/36] =?UTF-8?q?docs(design):=20contracts=20synced=20to?= =?UTF-8?q?=20shipped=20behavior;=20=C2=A711=20acceptance=20list=20settled?= =?UTF-8?q?;=20build-mode=20card=20gap=20logged?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The {list,key} grammar fixed in every contract example (the old form is refused by the shipped endpoint), the revision-selector contradiction resolved on both sides with the implemented equality rule and the recorded fallback, the on-acceptance list struck through with what actually happened, and the unsafe pre-review wording in decisions.md amended with why both halves were unsafe. --- .../contracts/execution-authorization.md | 38 +++++++++++++++---- .../contracts/read-config.md | 30 +++++++++++++-- .../contracts/workspace-import.md | 32 ++++++++++++++++ docs/design/agent-config-editing/decisions.md | 13 ++++++- .../agent-config-editing/open-issues.md | 9 +++++ .../spikes/runner-spike.md | 12 ++++++ 6 files changed, 120 insertions(+), 14 deletions(-) diff --git a/docs/design/agent-config-editing/contracts/execution-authorization.md b/docs/design/agent-config-editing/contracts/execution-authorization.md index bcfbae9128..a3da1c33b0 100644 --- a/docs/design/agent-config-editing/contracts/execution-authorization.md +++ b/docs/design/agent-config-editing/contracts/execution-authorization.md @@ -617,14 +617,36 @@ review is explicit that static inspection does not prove Daytona behavior. ## 11. Documents to update when this contract is accepted -- `spikes/runner-spike.md`, section "Where the resolution step should really live". Replace the - tool-call-id cache with this contract. -- `decisions.md`, the runner-spike block. Replace the "frozen per tool-call id, with inline - resolution at execution as the fallback" line. -- `decisions.md`, open product call 4. Record Mahmoud's answer on the forced gate. Gate 2 notes - that calls 4 and 8 are one decision, so merge them. -- `plan.md`. Split slice 3 into source codec, authorization and freeze integration, and approval - user interface, as must-fix item 7 requires. +**All four are done.** Kept as a record of what moved, so a later reader can tell a settled item +from an outstanding one. + +- ~~`spikes/runner-spike.md`, section "Where the resolution step should really live". Replace the + tool-call-id cache with this contract.~~ Done. The section carries a superseded banner: its + finding (resolve at the gate, freeze, execute the frozen bytes) stands and shipped; its + mechanism (a `toolCallId` cache with a resolve-inline-on-miss fallback) is marked as the + problem statement, not the design. +- ~~`decisions.md`, the runner-spike block. Replace the "frozen per tool-call id, with inline + resolution at execution as the fallback" line.~~ Done, as an amendment that keeps the original + wording visible and says why both halves were unsafe. +- ~~`decisions.md`, open product call 4. Record Mahmoud's answer on the forced gate. Gate 2 notes + that calls 4 and 8 are one decision, so merge them.~~ Done. Calls 4 and 8 were merged into + decision 3 and closed on 5 August: gate by default, inline only on an explicit `allow` verdict. + Section 4 states the same rule. +- ~~`plan.md`. Split slice 3 into source codec, authorization and freeze integration, and approval + user interface, as must-fix item 7 requires.~~ Done: S3a (import codec and readers), S3b + (authorization, wired into the gate), S3c (the approval card). + +### 11.1 What implementation changed in this contract's own terms + +Two things the implementation settled that this document did not anticipate. + +- **`catalogGeneration` did not exist.** Section 8 assumed it. It is now computed per + `adapter-matrix.md` section 2.4 — a strict canonical digest over the sorted tool document, + including the execution-plan fields and excluding what rotates. +- **The old text for the section 8.4 diff cannot come from `read_config` alone**, which is + head-only. The runner requires the read's `base_revision_id` to equal the operation's and fails + closed otherwise. `workspace-import.md` section 8.4.2.1 owns that rule and the recorded + fallback. ## 12. Gate 2 resolution diff --git a/docs/design/agent-config-editing/contracts/read-config.md b/docs/design/agent-config-editing/contracts/read-config.md index a20d2876d0..18c9e58e71 100644 --- a/docs/design/agent-config-editing/contracts/read-config.md +++ b/docs/design/agent-config-editing/contracts/read-config.md @@ -132,7 +132,7 @@ must not see. ``` `path` uses the change-set target grammar without any change: a string segment is an -object field, a `{"field", "key"}` segment is one named list entry. See `change-set.md` +object field, a `{"list", "key"}` segment is one named list entry. See `change-set.md` section 4. One grammar for read and write is the point. What the agent reads, it can then name in an operation. @@ -149,9 +149,9 @@ Examples: | the whole configuration | absent | | the model | `["parameters","agent","llm"]` | | the tool list | `["parameters","agent","tools"]` | -| one skill | `["parameters","agent",{"field":"skills","key":"release-qa"}]` | -| one skill body | `["parameters","agent",{"field":"skills","key":"release-qa"},"body"]` | -| one bundled file | `["parameters","agent",{"field":"skills","key":"release-qa"},{"field":"files","key":"scripts/check.py"},"content"]` | +| one skill | `["parameters","agent",{"list":"skills","key":"release-qa"}]` | +| one skill body | `["parameters","agent",{"list":"skills","key":"release-qa"},"body"]` | +| one bundled file | `["parameters","agent",{"list":"skills","key":"release-qa"},{"list":"files","key":"scripts/check.py"},"content"]` | ## 4. The response @@ -351,6 +351,28 @@ Two consequences we accept for v1: draft run, because the head holds different text. The failure is loud (`text_not_found`), which is the behavior we want. +### 10.2 The read is head-only, and the approval card depends on that + +This endpoint answers for the variant's CURRENT head. It has no revision selector: `target` +carries `workflow_variant_id`, `run_is_draft`, and `path`, and nothing else (section 3). + +That matters beyond this contract, because `workspace-import.md` section 8.4.2 requires the +approval card for a field replaced from a file to diff against the text at the operation's own +`base_revision_id` — not against whatever the session happens to be running. Those two facts look +contradictory and are not, because of one check. + +**The runner calls this endpoint for the operation's target and requires the response's +`base_revision_id` to equal the one the operation carries.** Equal means the head IS the base, so +the projected text is exactly the old side the commit replaces. Unequal fails the operation closed +with `source_base_unavailable`; it never diffs against the wrong side. `workspace-import.md` +section 8.4.2.1 carries the full argument, including why re-implementing this projection on the +runner was rejected and what the additive fallback would be. + +The consequence for THIS contract is small but real: a caller cannot use `read_config` to read a +revision that is no longer the head, and the card inherits that limit. It costs nothing a commit +would not already cost, since `base_revision_id` is a precondition and a stale base answers 409 +either way. + ## 11. The editable scope for commits R7 says server-owned fields stay outside the model's control. The prototype's commit diff --git a/docs/design/agent-config-editing/contracts/workspace-import.md b/docs/design/agent-config-editing/contracts/workspace-import.md index 1b203d8cac..ed6d18c09e 100644 --- a/docs/design/agent-config-editing/contracts/workspace-import.md +++ b/docs/design/agent-config-editing/contracts/workspace-import.md @@ -833,6 +833,38 @@ The base check in `commit-transaction.md` section 6 still runs. It catches a hea between the approval and the commit. It does not substitute for fetching the correct old side, because it compares revision identifiers and never compares the text the human read. +##### 8.4.2.1 How the runner obtains it, and why it is not a second projection + +`read_config` owns the projection of a target path out of a configuration — the same grammar the +operation's target uses, selectors included. It answers for the variant's CURRENT head, and it +carries no revision selector (`read-config.md` section 3). + +The runner therefore calls `read_config` for the operation's target and **requires the response's +`base_revision_id` to equal the one the operation carries**. Equal means the head IS the base, so +the projected text is the old side this operation replaces. Unequal fails the operation closed +with `source_base_unavailable`. + +Two properties make this the implemented rule rather than a workaround. + +1. **One projection, so the card cannot address the wrong field.** The alternative considered was + fetching the whole revision by id and re-implementing the segment walk on the runner. That + duplicates `read_config`'s resolution logic in a second language, and a drift between the two + would show the old text of a DIFFERENT field while the card still looked correct — the same + silent-wrongness class this section exists to prevent, merely relocated. Section 2 of this + contract already records why the whole-revision retrieve endpoint is not the instrument for a + partial read. +2. **The refusal blocks nothing that could have succeeded.** `base_revision_id` is a precondition + on the commit: when it is not the head, the commit itself answers 409 (`commit-transaction.md` + section 6). Requiring base to equal head at card time surfaces that same conflict one step + earlier, with the next step the model already knows — call `read_config`, re-anchor, resend. + +**Recorded fallback.** If that refusal is ever observed to bite in practice, the fix is additive +and small: accept an optional `revision_id` on the `read_config` request and resolve by revision +ref instead of variant head. The scope check, the projection, the refuse-don't-truncate rule, and +the `children` behavior all apply unchanged, and nothing in the runner moves except deleting the +equality check. It is deliberately NOT done pre-emptively, because it widens a model-facing +read surface for a case no run has yet hit. + #### 8.4.3 When the old text cannot be fetched: fail closed **Decided in answer to gate 3, arbitration ruling 1.** If the runner cannot obtain the old text diff --git a/docs/design/agent-config-editing/decisions.md b/docs/design/agent-config-editing/decisions.md index 0f54a9b3fd..b8f908d665 100644 --- a/docs/design/agent-config-editing/decisions.md +++ b/docs/design/agent-config-editing/decisions.md @@ -27,8 +27,17 @@ reasoning is in `spikes/engine-spike.md` (D1-D33, O1-O12) and `spikes/runner-spi ## Accepted from the runner spike (team lead, 4 August) -- `value_from` resolution runs at the permission gate, frozen per tool-call id, with - inline resolution at execution as the fallback for ungated calls. +- `value_from` resolution runs at the permission gate, with the resolved bytes frozen and + execution using those bytes rather than re-reading. + **Amended by `contracts/execution-authorization.md` (accepted):** the original wording said + "frozen per tool-call id, with inline resolution at execution as the fallback for ungated + calls", and both halves of that were unsafe. A tool-call id is correlation, not + authorization, so a forged relay record can reuse an approved id with different arguments; + and a cache-miss fallback lets an attacker skip the gate entirely by forging a record for a + call the runner never gated. The shipped rule is a single-use record binding the tool, the + arguments, the content, and the catalog generation, with inline resolution ONLY on an + explicit `allow` verdict from the permission plan (see the Contract-phase entry below, which + already states the narrowed rule). - The workspace reader is its own abstraction with two implementations: local `node:fs`, and a Daytona one-shot exec manifest (`find` + `realpath`), because the Daytona FS API has no mode bits and no symlink information. diff --git a/docs/design/agent-config-editing/open-issues.md b/docs/design/agent-config-editing/open-issues.md index b9653fb8d9..a820909624 100644 --- a/docs/design/agent-config-editing/open-issues.md +++ b/docs/design/agent-config-editing/open-issues.md @@ -57,3 +57,12 @@ reader can act on it cold. harness reads, not as text inside the stored instruction document. - Recorded together with the WHEN-to-commit skill guidance in the RFC artifact, section 9. + +## Build mode shows no readable approval card + +- Found by: engine-2 during S3b, 5 August 2026. Pre-existing dock behavior. +- The approval dock's per-tool bodies render only in Chat mode with an entity id; + Build mode always falls back to the raw payload block. So a folder-import + approval in Build mode shows JSON, not the manifest and diff. +- Fix direction: relax the renderer gating for manifest-carrying approvals, or + give Build mode a compact manifest body. Needs a small UX decision. diff --git a/docs/design/agent-config-editing/spikes/runner-spike.md b/docs/design/agent-config-editing/spikes/runner-spike.md index d3d5e0c3e1..e29c32a04d 100644 --- a/docs/design/agent-config-editing/spikes/runner-spike.md +++ b/docs/design/agent-config-editing/spikes/runner-spike.md @@ -82,6 +82,18 @@ card needs a stable digest. ### Where the resolution step should really live +> **Superseded in part by `contracts/execution-authorization.md` (accepted).** This section's +> central finding stands and is what shipped: resolution belongs at the permission gate, before +> the card, with the bytes frozen and execution using those bytes rather than re-reading. The +> MECHANISM it proposed does not stand. A cache keyed on `toolCallId` is not an authorization — +> a tool-call id is correlation, and the relay directory is writable from inside the sandbox, so +> a forged record can reuse an approved id with different arguments. Worse, the "resolve inline +> on a cache miss" fallback in the last bullet below lets an attacker AVOID the gate entirely by +> forging a record for a call the runner never gated. The contract replaces the cache with a +> single-use record binding the tool, the arguments, the content, and the catalog generation, +> and narrows the inline path to an explicit `allow` verdict from the permission plan. Read the +> bullets below as the problem statement, not the design. + The brief pointed at two seams. Neither is right on its own. **`assembleBody` in `tools/direct.ts` (~213-247) is the wrong layer.** That function merges the From 26b8c3e7b34ed099db3550ae8e3017bb3022c8f6 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Wed, 5 Aug 2026 19:18:24 +0200 Subject: [PATCH 30/36] docs: final review round outcomes (contracts synced to fixes, findings note, open issues) --- .../contracts/adapter-matrix.md | 17 ++- .../contracts/change-set.md | 8 +- .../contracts/read-config.md | 36 ++++-- .../contracts/workspace-import.md | 44 +++++++- .../notes/final-review-findings.md | 103 ++++++++++++++++++ .../agent-config-editing/open-issues.md | 45 +++++++- docs/design/agent-config-editing/plan.md | 15 ++- 7 files changed, 249 insertions(+), 19 deletions(-) create mode 100644 docs/design/agent-config-editing/notes/final-review-findings.md diff --git a/docs/design/agent-config-editing/contracts/adapter-matrix.md b/docs/design/agent-config-editing/contracts/adapter-matrix.md index 0e69b6d666..fb2b9db55b 100644 --- a/docs/design/agent-config-editing/contracts/adapter-matrix.md +++ b/docs/design/agent-config-editing/contracts/adapter-matrix.md @@ -586,10 +586,25 @@ unchanged. This contract changes individual routes inside it. Each step ships alone. No step changes reuse behavior until step 5. +**Status, and it decides what is routable today. Steps 1 and 2 have NOT shipped.** Neither +`ToolCatalogManifest` nor `ToolExecutionPlan` exists, and `runTurn` still reads its catalog from +`env.plan`, which is the plan the environment was BUILT with and is never replaced. +`env.reopenSession` closes over the same generation's session init, so a reopen reinstalls the old +MCP list, the old prompts and the old harness files. `reopen-session` therefore delivers nothing +new, and the sentence in section 4.4 that "`reopen-session` implies that reopening would deliver +it" is a statement about the target design, not about the runner as it stands. + +The consequence is enforced in code rather than left to a reader: `LIVE_ACTION_KINDS` excludes +`reopen-session`, so the `prompts`, `harnessFiles`, `harnessSession` and `toolCatalog` facets +rebuild. A rebuild is wasteful and always sound; a reopen that reports the incoming configuration +as applied after installing none of it is neither, and `harnessFiles` may BE a permission file. +Steps 1 and 2 are what let that route come back. + 1. Split tools into `ToolCatalogManifest` and `ToolExecutionPlan`. One generation. No behavior change. 2. Make `runTurn` build both from the incoming request. Remove the `env.plan` read at - `run-turn.ts:822`. No behavior change. + `run-turn.ts:822`. No behavior change. Re-admit `reopen-session` to `LIVE_ACTION_KINDS` in the + same change, with tests that assert the INSTALLED session init rather than the action name. 3. Add the untrusted best-effort acknowledgement path, dedicated and outside the relay directory. No behavior change. 4. Ship the Pi specs file and the extension hook, plus the Claude stdio shim capability and diff --git a/docs/design/agent-config-editing/contracts/change-set.md b/docs/design/agent-config-editing/contracts/change-set.md index bfe5332c2f..02f2d738ba 100644 --- a/docs/design/agent-config-editing/contracts/change-set.md +++ b/docs/design/agent-config-editing/contracts/change-set.md @@ -592,7 +592,13 @@ A warning is structured: - A collection is **item-touched** when an item operation names it. - A collection is **branch-touched** when a `set`, `merge`, `remove`, or a legacy `set` - writes it or an ancestor. A full-data commit branch-touches everything. + writes it or an ancestor. A full-data commit branch-touches everything. A write INSIDE a + selected entry also branch-touches the list that entry belongs to, because it can change + the entry's own key and collide with a sibling. +- A collection nested in a keyed list is identified by its parent's key as well as its own + name: `skills[alpha].files` and `skills[beta].files` are two collections, and neither + answers for the other. Without the parent key they collapse into one and the last entry + in the list decides the outcome for every entry. 1. An item-touched collection must end with no duplicate key: `duplicate_item_key`. 2. A branch-touched collection must not GAIN a duplicate. A key whose duplicate count rises diff --git a/docs/design/agent-config-editing/contracts/read-config.md b/docs/design/agent-config-editing/contracts/read-config.md index 18c9e58e71..5f40c62b4f 100644 --- a/docs/design/agent-config-editing/contracts/read-config.md +++ b/docs/design/agent-config-editing/contracts/read-config.md @@ -434,17 +434,37 @@ contract. ### 11.2 Where it runs -The policy is a parameter of `apply_change_set`. The commit wrapper picks it from the -caller: +The policy is a parameter of `apply_change_set`, and the ROUTE decides which policy is +passed. There are two commit routes over the same handler flow: -| Caller | Policy | -|---|---| -| the `commit_revision` platform tool | `AGENT_COMMIT_SCOPE` | -| a run override (RFC Q6, out of scope for v1) | `PARAMETERS_ONLY` | -| a human or SDK caller on the API | none | +| Route | Caller | Policy | +|---|---|---| +| `POST /api/workflows/revisions/commit/agent` | the `commit_revision` platform tool | `AGENT_COMMIT_SCOPE` | +| `POST /api/workflows/revisions/commit` | a human or SDK caller | none | +| (not built) a run override, RFC Q6 | out of scope for v1 | `PARAMETERS_ONLY` | + +The scoped route is the enforcement point, and the separation is what makes the +confinement unforgeable. The agent never chooses the URL: the path comes from the +server-side op catalog (`op_catalog.py`), the runner makes the call from OUTSIDE the +sandbox, and the sandbox holds no credential. So an agent cannot reach the unscoped route, +and there is no request field it could set or omit to widen its own scope. A signal carried +in the request instead, such as a header the runner adds, would fail OPEN whenever it went +missing; a route cannot go missing. + +**The unscoped route stays unscoped, by design.** A human editing in the playground and an +SDK caller own the whole revision, including `harness` and `sandbox`. Narrowing that route +would break every non-agent writer, and it protects nothing: those callers hold real +credentials and are already authorized for `EDIT_WORKFLOWS`. + +The scoped route also refuses a full-data commit (422, `full_data_not_committable`): a whole +configuration carries every field the scope exists to protect, so the shape is refused +rather than filtered. The agent's tool only ever sends a delta. The refusal is 422 with `out_of_scope`, and it is not retryable -(`change-set.md` section 10). +(`change-set.md` section 10). Both delta arms are scoped: the ordered arm checks every +operation's target, and the legacy arm walks the `set` tree deep enough to reach the +refused sub-paths, which are deeper than the allowed prefix. The refusal names the path it +refused, and its `next_step` names the subtree the agent may write. ### 11.3 A note on defence in depth diff --git a/docs/design/agent-config-editing/contracts/workspace-import.md b/docs/design/agent-config-editing/contracts/workspace-import.md index ed6d18c09e..df4d7b6e0a 100644 --- a/docs/design/agent-config-editing/contracts/workspace-import.md +++ b/docs/design/agent-config-editing/contracts/workspace-import.md @@ -129,7 +129,14 @@ the flag stays silent. The fix is a real file-descriptor-relative walk. The runner never rebuilds a full path string and never re-resolves from the root. -1. Open the import root once. Verify it with `fstat` on the handle. +1. Open the import root once, **with the no-follow flag set on that open too**, and verify it with + `fstat` on the handle. The root is a path component like any other: if `.agenta-imports` is + itself a symbolic link, an open without the flag follows it, and every descriptor-relative open + below is then faithfully confined to the LINK'S TARGET rather than to the workspace. The walk + below the root cannot recover from a root that was already redirected. A link here is refused + for its own sake, whatever it points at, so the reader never has to reason about where it + points; with `O_DIRECTORY` and `O_NOFOLLOW` together the errno alone cannot tell a link from a + plain file, so `lstat` names the entry when the message has to say which it was. 2. For each directory level, open the child **relative to the parent's descriptor**, with the no-follow and directory flags set. In Node this is `fs.open("/proc/self/fd//", ...)`, one component at a time. See the note @@ -559,6 +566,34 @@ relative to the folder it walked, so an entry cannot escape through its printed directory in the tree may still be a link, and rule 2 above makes `find` report it as `l`. The runner refuses it. +**The root is resolved against the WORKSPACE, and this is not optional.** `F` under `R` says +nothing when `R` itself was moved: every path under a relocated root is a faithful descendant of +the attacker's directory, and the descendant test passes exactly as designed. So the runner +resolves the workspace cwd as well, and requires `R` to be the workspace or to live under it, +before it trusts any comparison below the root: + +``` +realpath -- # -> W +realpath -- # -> R R must equal W or sit under it +realpath -- # -> F F must equal R or sit under it +``` + +**Every component's own type is checked, not only the final entry's.** `realpath` catches an +intermediate link that leaves the root, because the resolved target then fails the descendant +test. It does NOT catch one that stays inside: the resolved path is a legitimate descendant, so +the link is followed silently, and the local descriptor walk refuses the same tree. Two readers +that disagree about one tree is a contract defect on its own. So the Daytona reader walks the +whole path in ONE execution: + +``` +find / // ... -maxdepth 0 -printf '%y\0%m\0%s\0%P\0' +``` + +`find` takes many starting points, reports them in argument order, and prints an empty `%P` for a +starting point, so the records line up with the paths positionally. Any `l` on the chain refuses +the import, including the root's own entry. One call per component would cost one process per +level of every import, which is what section 6.1 rejected. + ### 6.3 What the manifest does NOT establish `find` resolves paths through the sandbox's own view of the filesystem, at the moment it runs. @@ -932,7 +967,14 @@ Every code carries the offending paths, up to 20, and a count of the rest. **Confinement.** - Traversal, absolute path, backslash, and NUL are refused before any read. - A symbolic link at the folder root is refused. +- A symbolic link IN PLACE OF the import root is refused, on both readers, whether its target + leaves the workspace or stays inside it. Assert on the content: a passing implementation must + not return the bytes the link points at. +- An import root that RESOLVES outside the workspace is refused, on both readers. - A symbolic link inside the folder is refused, even when its target stays inside the workspace. +- An INTERMEDIATE component that is a symbolic link is refused on both readers, including the case + where it resolves under the root. `realpath` alone passes that one, so a Daytona test that only + moves the link outside the root would not catch it. - A path outside `.agenta-imports/` but inside the workspace is refused. - A valid folder BELOW `.agenta-imports/` is accepted. This is the descendant test in section 6.2, and the gate 1 equality test would have failed it. Add it as a regression guard. diff --git a/docs/design/agent-config-editing/notes/final-review-findings.md b/docs/design/agent-config-editing/notes/final-review-findings.md new file mode 100644 index 0000000000..0b6361d5c5 --- /dev/null +++ b/docs/design/agent-config-editing/notes/final-review-findings.md @@ -0,0 +1,103 @@ +# Verdict: BLOCK + +The stack has four blocker-class defects in security and approval handling, plus major DAO and change-set correctness gaps. I reviewed the fetched `origin/release/v0.109.0...agent-config-editing-s3b-wire-web` range; the workspace was not modified. + +## Defects + +1. **Blocker — Import confinement can be moved outside the workspace with a symlinked import root.** + [workspace-reader.ts:163](/home/mahmoud/code/agenta-2/services/runner/src/tools/workspace-reader.ts:163), [workspace-reader.ts:421](/home/mahmoud/code/agenta-2/services/runner/src/tools/workspace-reader.ts:421) + + The local reader opens `.agenta-imports` without `O_NOFOLLOW`, so a root symlink is followed before descriptor-based traversal begins. The Daytona reader resolves both root and target, but if the root itself points outside the workspace, its descendant check still succeeds. Daytona also checks only the final entry’s own type, so intermediate symlinks are accepted. + + **Fix:** Open the local root with `O_NOFOLLOW | O_DIRECTORY`. For Daytona, `lstat` the root and every path component, reject every link, require the resolved root to remain beneath the resolved workspace cwd, then read. Add real root-symlink and intermediate-symlink tests for both implementations. + +2. **Blocker — A denied frozen import remains authorized while a sibling approval is parked.** + [acp-interactions.ts:365](/home/mahmoud/code/agenta-2/services/runner/src/engines/sandbox_agent/acp-interactions.ts:365), [run-turn.ts:925](/home/mahmoud/code/agenta-2/services/runner/src/engines/sandbox_agent/run-turn.ts:925), [run-turn.ts:1184](/home/mahmoud/code/agenta-2/services/runner/src/engines/sandbox_agent/run-turn.ts:1184) + + Both denial paths mark the tool call denied but never call `commitAuthorization.store.discardAll(toolCallId)`. The finalizer retains the entire authorization store whenever any sibling approval remains parked. A forged relay execution carrying the denied call’s exact ID and arguments can therefore consume the still-live record and commit the exact content the human rejected. + + **Fix:** Synchronously discard the denied call’s complete record set before replying to the harness, in both normal ACP and live-resume denial paths. Add a two-gate test: deny one, carry the other, then prove a forged execution for the denied call fails authorization. + +3. **Blocker — Live reconciliation records new configuration as applied while reinstalling the old configuration.** + [apply-plan.ts:80](/home/mahmoud/code/agenta-2/services/runner/src/environment/apply-plan.ts:80), [apply-plan.ts:109](/home/mahmoud/code/agenta-2/services/runner/src/environment/apply-plan.ts:109), [apply-plan.ts:149](/home/mahmoud/code/agenta-2/services/runner/src/environment/apply-plan.ts:149), [environment.ts:894](/home/mahmoud/code/agenta-2/services/runner/src/engines/sandbox_agent/environment.ts:894) + + Workspace refresh uses `env.plan`’s old instructions and skills, not the incoming request. Session reopen uses a closure capturing the original `sessionInit`, plan, MCP servers and harness configuration. `env.plan` is never replaced. Nevertheless, `commitApplied` records the incoming request’s fingerprint and facets. + + This can leave removed tools callable, removed MCP servers connected, tightened permissions unapplied, and stale skills readable—while subsequent turns believe the new configuration is installed and stop reconciling it. + + **Fix:** Until there is a real desired-plan installer, rebuild for every changed facet except model changes and proven credential rotation. Otherwise construct a new `RunPlan`, workspace payload, session initialization, MCP list and tool catalog from the incoming request, install them, and atomically replace the environment’s captured plan only after success. Tests must assert actual files, MCP servers, permission files and relay tool specs, not just action selection and applied-state digests. + +4. **Blocker — Build mode allows imported content to be approved without showing the frozen content or diff.** + [ApprovalDock.tsx:229](/home/mahmoud/code/agenta-2/web/oss/src/components/AgentChatSlice/components/ApprovalDock.tsx:229), [ApprovalDock.tsx:372](/home/mahmoud/code/agenta-2/web/oss/src/components/AgentChatSlice/components/ApprovalDock.tsx:372) + + Specialized approval bodies are disabled outside maximized Chat mode. Build mode renders only the model-visible payload. The runner-generated manifest is a sibling field and intentionally is not in that payload, so imported bytes, executable bits and diffs disappear while Approve remains available. + + **Fix:** Render `current.manifest` in every UI mode. Either enable the commit renderer in Build mode or append `ApprovedContentManifest` beside the raw payload. Test both live and replayed Build-mode approvals. + +5. **Major — DAO lock timeouts are swallowed and returned as successful empty commits.** + [dao.py:1569](/home/mahmoud/code/agenta-2/api/oss/src/dbs/postgres/git/dao.py:1569), [dao.py:1627](/home/mahmoud/code/agenta-2/api/oss/src/dbs/postgres/git/dao.py:1627), [service.py:2048](/home/mahmoud/code/agenta-2/api/oss/src/core/workflows/service.py:2048), [router.py:1637](/home/mahmoud/code/agenta-2/api/oss/src/apis/fastapi/workflows/router.py:1637) + + `commit_revision` excludes only revision-conflict exceptions from generic suppression. PostgreSQL’s lock-timeout exception is consequently converted to `None`; the service wraps that as `status="committed"`, and the router invalidates cache and returns a committed response with `count: 0`. The contract requires a retryable 503. + + **Fix:** Detect SQLSTATE `55P03`, translate it into an explicit commit-lock-timeout exception, exclude it from suppression, and map it to HTTP 503. Add an endpoint integration test holding the variant lock beyond the configured timeout. + +6. **Major — The checked commit is not the atomic checked transaction required by the contract.** + [service.py:2020](/home/mahmoud/code/agenta-2/api/oss/src/core/workflows/service.py:2020), [service.py:2085](/home/mahmoud/code/agenta-2/api/oss/src/core/workflows/service.py:2085), [service.py:2200](/home/mahmoud/code/agenta-2/api/oss/src/core/workflows/service.py:2200), [dao.py:1658](/home/mahmoud/code/agenta-2/api/oss/src/dbs/postgres/git/dao.py:1658) + + Delta application and no-change detection happen before the lock. A stale caller whose operation resolves to no change receives `200 no_change` without the locked head comparison and can miss a concurrent head change. Equality is also evaluated before snippet normalization, schema enrichment and inferred flags; full-data commits skip no-change comparison entirely. + + **Fix:** Implement the contract’s checked DAO sibling/build callback: lock the variant, re-read and validate the base, synchronously build/validate/normalize/enrich/infer flags, compare canonical persisted `{data, flags}`, then insert or return no-change in the same transaction. + +7. **Major — UTF-8 validation accepts malformed byte sequences and silently changes imported content.** + [workspace-reader.ts:76](/home/mahmoud/code/agenta-2/services/runner/src/tools/workspace-reader.ts:76) + + Invalid UTF-8 is rejected only when decoded text contains `U+FFFD` and the input contains no `0xEF` byte. An invalid sequence containing `0xEF` bypasses the check, is decoded with replacement characters, and is then digested after transformation. + + **Fix:** Decode using `new TextDecoder("utf-8", {fatal: true})`, then perform the NUL check. Test malformed sequences beginning with `0xEF` and valid text mixed with malformed bytes. + +8. **Major — Cold approval resume consumes the stale approval before failing authorization instead of immediately issuing a new gate.** + [responder.ts:316](/home/mahmoud/code/agenta-2/services/runner/src/responder.ts:316), [permission-plan.ts:146](/home/mahmoud/code/agenta-2/services/runner/src/permission-plan.ts:146), [commit-authorization.ts:368](/home/mahmoud/code/agenta-2/services/runner/src/tools/commit-authorization.ts:368) + + A cold environment has no frozen authorization records, but the replayed gate still consumes the inbound stored `allow` decision and replies allow to the harness. Execution then fails with `authorization_missing`. The contract requires ignoring that stale answer, resolving the file again and presenting a fresh approval immediately. + + **Fix:** Make cold replay marker-aware: an old approval must not answer a newly resolved marker gate. Mint new frozen records and return `pendingApproval`. Add end-to-end cold-resume tests through both ACP gating and relay authorization. + +9. **Major — Explicit ordered `set` operations with `value: null` are converted into missing-value errors.** + [dtos.py:302](/home/mahmoud/code/agenta-2/api/oss/src/core/workflows/dtos.py:302), [service.py:2310](/home/mahmoud/code/agenta-2/api/oss/src/core/workflows/service.py:2310), [change_set.py:864](/home/mahmoud/code/agenta-2/api/oss/src/core/workflows/change_set.py:864) + + `value` is optional, but operations are dumped with `exclude_none=True`. An explicitly supplied JSON null disappears before the engine, which then reports `missing_operation_value`. The contract says null is a valid value. + + **Fix:** Serialize using presence semantics—such as `exclude_unset=True`—so explicit null is preserved while an omitted field remains absent. Test this through the HTTP boundary. + +10. **Major — The API silently accepts mixed delta forms and can persist unresolved file markers through the legacy arm.** + [dtos.py:330](/home/mahmoud/code/agenta-2/api/oss/src/core/workflows/dtos.py:330), [service.py:2235](/home/mahmoud/code/agenta-2/api/oss/src/core/workflows/service.py:2235) + + `WorkflowRevisionDelta` has no closed-form validator. If `operations` is present, `set` and `remove` are silently ignored instead of rejecting the mixed form. Legacy `set` is deep-merged directly, bypassing the engine’s unresolved `@ag.file` rejection. + + **Fix:** Forbid unknown fields and validate that exactly one delta form is used. Route the legacy form through the shared change-set validation boundary, or explicitly run marker rejection before merging. Test mixed forms, unknown keys and legacy unresolved markers. + +11. **Major — Nested keyed-list uniqueness checks alias collections belonging to different parent items.** + [change_set.py:1125](/home/mahmoud/code/agenta-2/api/oss/src/core/workflows/change_set.py:1125), [change_set.py:1137](/home/mahmoud/code/agenta-2/api/oss/src/core/workflows/change_set.py:1137), [change_set.py:1163](/home/mahmoud/code/agenta-2/api/oss/src/core/workflows/change_set.py:1163) + + Selector paths retain only the list name, not the selected parent key. Collection traversal likewise walks list entries without extending the path, causing later siblings to overwrite earlier ones. With multiple skills containing nested `files` lists, the engine can reject duplicates in an untouched skill or accept duplicates in the touched skill, depending on sibling order. + + **Fix:** Represent paths with structured selector identity, including each keyed parent’s list name and item key. Add tests with several skills where different `files` collections contain different duplicate states. + +12. **Major — The specialized approval card shows the persisted commit message as intent and drops the actual per-call description.** + [CommitRevisionApproval.tsx:24](/home/mahmoud/code/agenta-2/web/oss/src/components/AgentChatSlice/components/approvals/CommitRevisionApproval.tsx:24) + + The card reads `input.workflow_revision.message`. The contract places agent-stated intent in outer `input.description` and requires presenting it explicitly as model-authored intent beside the real diff. When preview generation succeeds, the raw fallback is hidden, so the actual description is never visible. + + **Fix:** Read and label `input.description` as agent-stated intent. Keep `workflow_revision.message` separately if useful, without presenting it as factual intent. Test with a production-shaped payload containing distinct description and message values. + +## DAO lane: CTO structural concerns + +- **The lock is opt-in, not a GitDAO-wide serialization invariant.** [dao.py:1615](/home/mahmoud/code/agenta-2/api/oss/src/dbs/postgres/git/dao.py:1615) locks only calls carrying `initial` or `expected_head_revision_id`. Unchecked `commit_revision` callers and `create_revision` do not participate. A dedicated checked primitive would communicate this boundary more safely than optional parameters on the shared method. + +- **Head membership can change outside the variant lock.** [dao.py:1223](/home/mahmoud/code/agenta-2/api/oss/src/dbs/postgres/git/dao.py:1223) and [dao.py:1265](/home/mahmoud/code/agenta-2/api/oss/src/dbs/postgres/git/dao.py:1265) archive/unarchive revisions without taking the variant lock, even though `deleted_at` determines the active head. If the invariant is “serialize changes to a variant head,” these paths must participate or be explicitly excluded with a proven concurrency argument. + +- **The variant lock query does not verify that it locked a row.** [dao.py:1634](/home/mahmoud/code/agenta-2/api/oss/src/dbs/postgres/git/dao.py:1634) ignores the query result. A missing or cross-project variant therefore proceeds unlocked until a later failure, which generic suppression can turn into `None`. Require exactly one variant row before continuing. + +- **The serialized unit ends before version bookkeeping.** [dao.py:1692](/home/mahmoud/code/agenta-2/api/oss/src/dbs/postgres/git/dao.py:1692) explicitly commits and releases the lock before `_get_version`, `_set_version`, and version-zero normalization. If version assignment is part of the revision invariant, it belongs inside the same transaction. + +I found no additional confirmed defect in the sampled credential-delivery port, relay call-before-execute hook, strict catalog serialization, or Vercel SSE conversion. diff --git a/docs/design/agent-config-editing/open-issues.md b/docs/design/agent-config-editing/open-issues.md index a820909624..e1a87678e9 100644 --- a/docs/design/agent-config-editing/open-issues.md +++ b/docs/design/agent-config-editing/open-issues.md @@ -61,8 +61,43 @@ reader can act on it cold. ## Build mode shows no readable approval card - Found by: engine-2 during S3b, 5 August 2026. Pre-existing dock behavior. -- The approval dock's per-tool bodies render only in Chat mode with an entity id; - Build mode always falls back to the raw payload block. So a folder-import - approval in Build mode shows JSON, not the manifest and diff. -- Fix direction: relax the renderer gating for manifest-carrying approvals, or - give Build mode a compact manifest body. Needs a small UX decision. +- CLOSED at the final review, 5 August 2026 (finding 4): the dock now renders the + frozen-content manifest in its fallback branch, so Build mode and the + entity-less host always show the files, digests, and diff next to the payload. + The larger question (running the full specialized card body in Build mode) + stays open as a UX decision; the contract's "a human approves a readable + change" rule no longer depends on it. + +## Shadow router logs a permanent DISAGREE for rebuilt reopen facets + +- Found by: the final review fix round, 5 August 2026 (while narrowing live + routes, finding 3). +- The four facets that reopen-session would cover now escalate to rebuild, but + `PlanOutcome` derives from `maxAction` and only `rebuild-sandbox` counts as + "rebuild", so the shadow router logs `plan=reuse(reopen-session) DISAGREE` for + them. `restart-runtime` produced the same permanent disagreement before this + change; the semantics were left alone to avoid reshaping `buildPlan` and its + shadow tests during the fix round. +- The ask: if the shadow counters are meant to be actionable signals, make + `PlanOutcome` reflect the routed action, not `maxAction`. + +## Pi refresh refuses any request that carries skills + +- Decided at the final review fix round, 5 August 2026, taking the sound side. +- On Pi, `skillRootFor` returns undefined, so a workspace refresh cannot install + skill directories. The refresh arm now refuses whenever the request carries + skills on a Pi run, and the caller rebuilds. This over-refuses: a Pi run that + has skills but changes only its instructions rebuilds where a refresh would + have been enough. +- The ask: narrow the refusal to a real skills diff (request skills differ from + applied skills) once a test asserts the installed skill tree on Pi. + +## Cold-resume re-gate shipped without a Verdict source marker + +- Decided at the final review fix round, 5 August 2026 (finding 8). +- The re-gate predicate infers "this allow came from a stored decision" from + `effectivePermission(gate, plan) !== "ask"` being false, instead of a + `source: "policy" | "stored"` field on `Verdict` (adding the field broke + eleven exact-shape assertions in unrelated tests). The inference is sound + today because exactly one path consults the stored-decision store. If a second + stored-decision path ever appears, add the explicit field. diff --git a/docs/design/agent-config-editing/plan.md b/docs/design/agent-config-editing/plan.md index d12aa2101b..e8e6c74d1a 100644 --- a/docs/design/agent-config-editing/plan.md +++ b/docs/design/agent-config-editing/plan.md @@ -56,11 +56,20 @@ model-visible in the catalog until `read_config` exists. An agent that can be to | S6 | Coordinator extraction + shadow routing (steps 3-4). | S5 | | S7a | Lifecycle extraction into units (step 5), behavior unchanged. | S6 | | S7b | In-place routes for workspace files and model (step 6, first half). | S7a | -| S7c0 | Foundation: the ToolCatalogManifest / ToolExecutionPlan split with one shared generation, and per-turn execution-plan wiring (kills the stale run-turn.ts mix). | S7a | -| S7c | Tool-catalog routes with the untrusted best-effort acknowledgement per `contracts/adapter-matrix.md`. | S7c0, spike S2 verdicts | -| S7d | MCP reopen with positive native-history verification. | S7a | +| S7c0 | NOT SHIPPED. Foundation: the ToolCatalogManifest / ToolExecutionPlan split with one shared generation, and per-turn execution-plan wiring (kills the stale run-turn.ts mix). | S7a | +| S7c | NOT SHIPPED. Tool-catalog routes with the untrusted best-effort acknowledgement per `contracts/adapter-matrix.md`. | S7c0, spike S2 verdicts | +| S7d | Session reopen on the same sandbox, gated on replayability. Landed, but NOT routable: see the note below. | S7a | | S7e | Credential and provider reconciliation, including the Daytona creation-identity split (steps 8-9). | S7a | +S7c0 and S7c did not ship, and the external review caught what that costs. `runTurn` still reads +its tool catalog from `env.plan`, and `env.reopenSession` closes over the session init the +environment was built with, so a reopen reinstalls the OLD MCP list, prompts and harness files +while `commitApplied` records the incoming ones. Rather than leave a route that reports a +configuration it did not install, `reopen-session` is excluded from `LIVE_ACTION_KINDS`: the +`prompts`, `harnessFiles`, `harnessSession` and `toolCatalog` facets rebuild until S7c0 lands. +S7d's machinery stays in place for that day. `contracts/adapter-matrix.md` section 8 carries the +same note beside the rollout steps. + Accepted risk, recorded: the Daytona import manifest cannot fully prevent a content-swap during the read window under an adversarial sandbox. The two-pass check detects inconsistency; it is not a snapshot. workspace-import.md §6.5 states the attack From cdc6c1d86bdb69682bd681cb3a43c40508275194 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Wed, 5 Aug 2026 19:30:07 +0200 Subject: [PATCH 31/36] docs: final status and the E2 enforcement-seam ruling --- docs/design/agent-config-editing/decisions.md | 19 +++ docs/design/agent-config-editing/status.md | 156 ++++++++---------- 2 files changed, 91 insertions(+), 84 deletions(-) diff --git a/docs/design/agent-config-editing/decisions.md b/docs/design/agent-config-editing/decisions.md index b8f908d665..2bb19d29ea 100644 --- a/docs/design/agent-config-editing/decisions.md +++ b/docs/design/agent-config-editing/decisions.md @@ -276,3 +276,22 @@ handoff on 5 August. The original option text stays below for the record. scope section.) Recommended: no. 6. **Store the authored operations for audit** (blocks S1b persistence design). Recommended: yes, on the revision commit record. + +## Agent scope enforcement seam (team-lead, 5 August final review, item E2) + +- Problem: `AGENT_COMMIT_SCOPE` (decisions 4 and 5: the agent may not write + `harness.kind`, `harness.permissions`, `runner.permissions`, `sandbox.kind`, + `sandbox.permissions`) existed but was never applied. The agent's commit is + relayed by the runner, which makes the HTTP call itself with a body identical + to a human's, so the API had no signal to scope on. +- Rejected: a runner-minted header (fail-open: an omitted header silently grants + full scope) and moving the tool to handler mode (sound, but it changes the + transport after the execution-authorization wiring was verified against the + current relay branch; too much late churn). +- Ruling: a scoped sibling route, `POST /api/workflows/revisions/commit/agent`, + same handler flow with the scope policy hard-applied on both delta arms and a + refusal of full-data commits. The op catalog points the agent's tool at this + route in both flag states. Fail-closed by construction: the model holds no API + credential and the path comes from server-side catalog code, so an unscoped + agent commit cannot be expressed. The human and SDK route stays unscoped by + design. Details: contracts/read-config.md §11.2. diff --git a/docs/design/agent-config-editing/status.md b/docs/design/agent-config-editing/status.md index 4eb5adbe90..3953928124 100644 --- a/docs/design/agent-config-editing/status.md +++ b/docs/design/agent-config-editing/status.md @@ -1,86 +1,74 @@ # Status -Updated: 2026-08-04 night, by team-lead. - -## Implementation state - -Two slices are landed on stacked lanes and pushed, with the gate-3 contract fixes: - -- Lane `agent-config-editing-plan`: all design docs and contracts. -- Lane `agent-config-editing-s5` (stacked on plan): runner applied-state identity and - safe teardown. The approval-stale-config bug is now unrepresentable; a config change - stops the sandbox instead of deleting it; a content-identical commit keeps the warm - session. 14 files. -- Lane `agent-config-editing-s4` (stacked on s5): the agent-written description on - builder tool calls, stripped before dispatch, shown on the agent chat tool card. 12 - files. - -The qa teammate is verifying all suites in the main tree. Everything else waits on the -six product calls in `decisions.md` (call 1 gates the engine slice) and a fourth gate -pass over the corrected contracts. - -## Where we are - -Phase 1 exit review. Both spikes are complete and green: - -- engine-spike: the pure engine works, 120 tests, legacy parity proven against the real - service code. Report: `spikes/engine-spike.md`. -- runner-spike: value_from proven end to end (34 tests), the tools-discovery verdict is - in (Pi and Claude are blocked by our own delivery, not by the harness; Codex needs a - session reopen), and 15 characterization tests pin today's lifecycle behavior. - Report: `spikes/runner-spike.md`. - -The consolidated decisions are in `decisions.md`. Seven product calls wait on Mahmoud -(listed there). Draft PR: https://github.com/Agenta-AI/agenta/pull/5733. - -## Design gate: NO-GO (first pass) - -The Codex design gate review (`research/design-gate-review-codex.md`) returned NO-GO -with eight must-fix items before implementation. The largest: the value_from approval -flow needs a single-use execution authorization (the toolCallId cache is forgeable via -the relay directory); the commit transaction and the no-change response are -unspecified; read_config, the editable-scope policy, and the description field need -real contracts; live tool routes need an applied-generation acknowledgement; the slice -plan understates dependencies and slices 1, 3, 7 are too big. - -Update: all six contracts are written in `contracts/` (change-set, commit-transaction, -read-config from engine-spike; execution-authorization, workspace-import, -adapter-matrix from runner-spike). The one cross-contract conflict (the value_from -schema) is arbitrated and recorded in `decisions.md`. The second gate review runs now. -Twelve product calls are open for Mahmoud in `decisions.md`. - -Original response for the record: the team writes the missing contracts before any -slice starts. engine-spike -owns the change-set contract, the commit transaction and response, and the read_config -contract. runner-spike owns the execution authorization, the workspace import -boundary, and the corrected adapter matrix. Second gate review after that. Fail-closed -defaults adopted meanwhile: value_from always gates, imports come from a designated -root, unsupported files reject the source unless the caller opts into omission, -executable policy is explicit and default-deny, no blanket text normalization. - -## Decisions taken (4 August review with Mahmoud) - -- Edits: ordered operations with anchored text edits and named list entries (RFC Q1 - Option B; interface per `research/change-set-interface-codex.md`). -- Large content: the runner reads workspace files and inlines them before the API sees - the call (RFC Q2 Option B). -- Config reads: a `read_config` tool with partial reads; no config file in the - workspace (RFC Q3 Option B). -- Concurrency: base check on commit, no locks (RFC Q4 Option A). -- Sessions: update in place, rebuild only for harness and sandbox changes (RFC Q5 - Option B). Harnesses not re-reading files on their own is accepted behavior. Open - question is tools only (Spike S2). The approval-path stale-config bug is fixed inside - this work, not separately. -- US-6 (run without saving) is out of scope. -- New requirement R12: optional agent-written description on builder tool calls. -- Scope of the PR set: full runner lifecycle refactor included. Frontend minimal. - -## Blockers - -None. - -## Waiting on - -- Spike reports (tasks #2, #3), expected in `spikes/`. -- Mahmoud: none right now. Product calls surfaced by the spikes will be brought to him - at the phase 1 exit gate (task #4). +Updated: 2026-08-05 evening, by team-lead. + +## Implementation state: complete, fix round landed, PRs opening + +All in-scope user stories (US-1, US-2, US-3, US-4, US-5, US-7, US-8) are implemented +behind the single feature flag `AGENTA_WORKFLOWS_ORDERED_OPERATIONS_ENABLED`. US-6 is +out of scope by decision. + +The work sits on eighteen stacked GitButler lanes, bottom to top: plan (design docs) +→ s5 → s4 → s1a → s6 → s1b-lock → s1b → s7a → s2 → s7b → s3a → s7c → s3b-core → +s7d → s7e → s3b-wire-runner → s3b-wire-py → s3b-wire-web. Every lane is pushed and +verified against the remote. The stack base is the release/v0.109.0 merge base +(4165aa81df); PR bases follow the stack, bottom lane targets release/v0.109.0, never +main. + +## Final review round (5 August, evening) + +The external reviewer (Codex, highest reasoning) returned BLOCK on the full stack +diff: four blocker-class findings, eight majors, four structural notes on the dao +lane. Full text: `notes/final-review-findings.md`. Every finding was independently +verified against the code before any fix; two sub-claims were refuted with proof, +two findings matched recorded scope decisions, and the rest were confirmed and +fixed. The verification also surfaced three defects the reviewer missed (lost +exception decorators on the commit route, the never-applied agent scope policy, the +unwired final-validation gate) plus a legacy scope-walk depth bug found during the +E2 fix itself. All fixes are landed on their owning lanes. + +Headline outcomes: + +- The agent commit tool now posts to a scoped sibling route + (`/api/workflows/revisions/commit/agent`) that hard-applies `AGENT_COMMIT_SCOPE` + on both delta arms and refuses full-data commits. Enforcement is a property of + the code path; the model holds no credential and cannot reach the unscoped + route. read-config.md §11.2 records the design. +- A denied approval now discards its execution authorization before the harness is + answered; a forged relay execution for a denied call fails closed. +- Live reconciliation routes are narrowed to what is actually installable + (workspace refresh from the incoming request, model apply-live, credential + rotation); everything reopen-session claimed to cover escalates to rebuild until + the S7c0 execution-plan split lands. adapter-matrix.md §8 records the boundary. +- The import root opens no-follow on both readers; malformed UTF-8 is refused by a + fatal decoder; Build mode always renders the frozen approval manifest. +- The commit transaction surfaces lock timeouts as 503, never claims "committed" + without a revision, compares no-change after enrichment, and routes both delta + arms through the engine's classification (a legacy set can no longer commit + build-kit tools or bypass marker rejection). + +Accepted scope boundaries, recorded not fixed: the full atomic build-callback +transaction (commit-transaction.md §3.1, dao lock note §4), dao structural bullets +1 and 4 (opt-in lock boundary, version bookkeeping outside the lock), and the +follow-ups listed in `open-issues.md`. + +## Verification state + +After the fix round, on the landed tree: runner 114 files / 1913 tests, typecheck +clean; API full unit suite 1911 both flag states (single failure is an unrelated +untracked repro in the tree, not part of this stack); SDK 797 agent tests plus the +catalog suites both flag states; web AgentChatSlice 95 tests, project typecheck +clean; repo-pinned ruff clean in api/ and sdks/python/. + +Live QA on a deployed stack is deliberately deferred: Mahmoud will exercise the +deployed stack himself when the PRs are ready (his call, 5 August). The dev box had +no capacity for a fifth stack and no running stack may be torn down without his +naming it. + +## Remaining + +- Open the stacked PRs with per-lane descriptions and inline comments (in + progress). +- Docs sync for the changed public surfaces. +- Codex upstream issue (live MCP tool updates) stays drafted in + `spikes/runner-spike.md`; filing needs Mahmoud's explicit approval. From 988403a129739e0a6563d85cd60ededde81f75d0 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Wed, 5 Aug 2026 23:16:08 +0200 Subject: [PATCH 32/36] docs: E2E campaign outcomes (ancestor scope rule, refresh env.plan wording, open issues) --- .../contracts/adapter-matrix.md | 5 ++- .../contracts/read-config.md | 10 +++++ .../agent-config-editing/open-issues.md | 43 +++++++++++++++++++ 3 files changed, 57 insertions(+), 1 deletion(-) diff --git a/docs/design/agent-config-editing/contracts/adapter-matrix.md b/docs/design/agent-config-editing/contracts/adapter-matrix.md index fb2b9db55b..e84e83fb9e 100644 --- a/docs/design/agent-config-editing/contracts/adapter-matrix.md +++ b/docs/design/agent-config-editing/contracts/adapter-matrix.md @@ -588,7 +588,10 @@ Each step ships alone. No step changes reuse behavior until step 5. **Status, and it decides what is routable today. Steps 1 and 2 have NOT shipped.** Neither `ToolCatalogManifest` nor `ToolExecutionPlan` exists, and `runTurn` still reads its catalog from -`env.plan`, which is the plan the environment was BUILT with and is never replaced. +`env.plan`. Workspace refresh now replaces the prompt and workspace portions of `env.plan`, and it +does so only after the install succeeds. The tool catalog and the captured session initialization +still come from the generation the environment was BUILT with, so a stale catalog is still +possible until steps 1 and 2 ship. `env.reopenSession` closes over the same generation's session init, so a reopen reinstalls the old MCP list, the old prompts and the old harness files. `reopen-session` therefore delivers nothing new, and the sentence in section 4.4 that "`reopen-session` implies that reopening would deliver diff --git a/docs/design/agent-config-editing/contracts/read-config.md b/docs/design/agent-config-editing/contracts/read-config.md index 5f40c62b4f..42deef2ed9 100644 --- a/docs/design/agent-config-editing/contracts/read-config.md +++ b/docs/design/agent-config-editing/contracts/read-config.md @@ -413,6 +413,16 @@ An agent that could widen its own permission lists could grant itself any tool. that could switch its sandbox could leave the boundary a human chose. Both are privilege escalation, and both are silent. +**A write to an ancestor of a refused path is a write to that path.** Naming +`parameters.agent.harness` and sending `{"kind": "codex"}` changes the same stored field as +naming `parameters.agent.harness.kind`, so the rule is stated on the result and not on the +target: whatever an operation would leave at a refused path must equal what is stored there +now. An omission counts as a write for `set`, which replaces its target wholesale, and for +`remove`, which deletes it; it does not count for `merge`, which leaves absent keys alone. +The alternative, refusing every write to the three selector objects, was rejected because +`harness.extras` and `runner.kind` are not refused and sit beside keys that are: it would +have cost real capability to close the hole, and this rule costs none. + #### 11.1.1 Two v1 defaults, both fail-closed Gate 2 says product calls 10 and 11 leave the security scope unfinished, and that the diff --git a/docs/design/agent-config-editing/open-issues.md b/docs/design/agent-config-editing/open-issues.md index e1a87678e9..cb6a6744f5 100644 --- a/docs/design/agent-config-editing/open-issues.md +++ b/docs/design/agent-config-editing/open-issues.md @@ -101,3 +101,46 @@ reader can act on it cold. eleven exact-shape assertions in unrelated tests). The inference is sound today because exactly one path consults the stored-decision store. If a second stored-decision path ever appears, add the explicit field. + +## Editing the workspace instructions file does not persist + +- Found by: the UI E2E campaign (cell U7), 5 August 2026. +- A model asked to change its instructions edited the materialized workspace copy + (AGENTS.md) with file tools. That copy is rebuilt from the stored configuration, + so the approved edit silently disappeared. Guidance shipped in v1: both commit + tool descriptions and the build-an-agent skill now state that workspace edits + do not change the stored configuration. +- The ask, if it recurs: enforce rather than advise. Either mount the + materialized instruction file read-only, or warn when a turn ends with + uncommitted edits to it. + +## Deny narration on ACP harnesses is harness-authored + +- Found by: the UI E2E campaign (cell U5) and the runner fix round, 5 August 2026. +- Every runner-authored refusal string now states plainly that the user declined + the specific change and that reshaped retries are pointless + (src/tools/denial-text.ts). On the Claude and Codex ACP path, the text the + model reads comes from the harness closing the call, not from the runner, so a + misleading "blocked by policy" narration can still occur there. +- The ask: give the deny path a runner-owned message on ACP harnesses, or have + the SDK render tool-output-denied with user-decline framing. +- Related: environment-setup.ts's "denied by policy" fires on a no-turn-wired + race, not a denial; it needs its own message. + +## The legacy delta classifier needs a decision: teach or retire + +- Found by: the card fix round, 5 August 2026. +- classifyRevisionDeltaChanges in @agenta/entities reads only the legacy + {set, remove} delta. The approval card now describes ordered operations + through its own reader, so the classifier is effectively dead for agent + commits (its one production caller). Either teach the package the ordered + form deliberately, or retire the function when the legacy arm goes. + +## The card cannot show which stored fields a wholesale write replaces + +- Found by: the U10 post-mortem, 5 August 2026. +- The engine now refuses wholesale writes that touch platform-owned paths, and + the card's Now/After blocks show the full old and new objects, so an omitted + key is visible to a careful reader. The residual: for large objects a subtle + omission is easy to miss. A card that lists the affected stored paths for a + wholesale set would close the class. From d4f0707942acb0a8e21d2478184155f13a1bae2b Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Wed, 5 Aug 2026 23:46:59 +0200 Subject: [PATCH 33/36] docs: CodeRabbit review corrections (superseded banners, counts, spike harness fixes) --- docs/design/agent-config-editing/README.md | 19 ++++-- .../contracts/workspace-import.md | 21 ++++++- docs/design/agent-config-editing/research.md | 11 +++- .../agent-config-editing/research/rfc.html | 1 + .../research/runner-lifecycle-codex.md | 4 ++ .../spikes/model-usability-spike.md | 7 ++- .../spikes/model-usability/analyze.py | 16 ++++- .../spikes/model-usability/harness.py | 34 ++++++---- .../spikes/model-usability/instructions/v1.md | 6 +- .../spikes/model-usability/instructions/v2.md | 6 +- .../spikes/model-usability/instructions/v3.md | 2 +- .../spikes/model-usability/run.py | 8 +++ .../spikes/model-usability/table.py | 63 ++++++++++++------- .../spikes/model-usability/tasks.py | 6 ++ .../spikes/runner-spike.md | 16 ++++- 15 files changed, 161 insertions(+), 59 deletions(-) diff --git a/docs/design/agent-config-editing/README.md b/docs/design/agent-config-editing/README.md index fd5e9b1db4..17a1189e1b 100644 --- a/docs/design/agent-config-editing/README.md +++ b/docs/design/agent-config-editing/README.md @@ -12,12 +12,21 @@ so small configuration changes stop forcing a full sandbox rebuild. | `context.md` | Why this work exists. Goals, non-goals, user stories. | | `plan.md` | The execution plan: slices, order, QA gates. | | `status.md` | Where the work stands right now. Decisions and blockers. | +| `contracts/change-set.md` | The change-set engine, decided. Where any other document disagrees with it, this one wins. | +| `contracts/execution-authorization.md` | Execution authorization for workspace file references, decided. | +| `contracts/workspace-import.md` | The workspace import boundary, decided. | +| `contracts/adapter-matrix.md` | The harness reconciliation matrix, decided. | +| `contracts/commit-transaction.md` | The atomic commit transaction and its wire response, decided. | +| `contracts/read-config.md` | `read_config`, the editable scope, and the call description, decided. | | `research.md` | What the codebase research found, with file references. | -| `research/rfc.html` | The full RFC: requirements, design questions, decided options. | -| `research/change-set-interface-codex.md` | The change-set interface spec (external design review, accepted as working draft). | -| `research/runner-lifecycle-codex.md` | The runner lifecycle architecture and its migration path. | -| `spikes/engine-spike.md` | Findings from the change-set engine prototype. | -| `spikes/runner-spike.md` | Findings from the runner-side spikes. | +| `research/rfc.html` | The original RFC: requirements, design questions. Historical context; the `contracts/` files carry the decided answers. | +| `research/change-set-interface-codex.md` | The pre-consolidation change-set interface spec. Historical; superseded by `contracts/change-set.md`. | +| `research/runner-lifecycle-codex.md` | The pre-consolidation runner lifecycle architecture. Historical; superseded by `contracts/adapter-matrix.md`. | +| `spikes/engine-spike.md` | Findings from the change-set engine prototype. Historical. | +| `spikes/runner-spike.md` | Findings from the runner-side spikes. Historical. | + +The `contracts/` files are the implementation source of truth. `research/` and `spikes/` +record how we got there; read them for context, not for the current behavior. ## Glossary diff --git a/docs/design/agent-config-editing/contracts/workspace-import.md b/docs/design/agent-config-editing/contracts/workspace-import.md index df4d7b6e0a..dd723dccc7 100644 --- a/docs/design/agent-config-editing/contracts/workspace-import.md +++ b/docs/design/agent-config-editing/contracts/workspace-import.md @@ -20,8 +20,13 @@ answers must-fix item 5 of `research/design-gate-review-codex.md`. > its truncation rules (section 8). Read every "folder" in those sections as "the file a > marker names", and read every "the import" as "one marker's resolution". > -> Sections 4.2, 4.3, 5.2, and 8.1's `allowExecutableFiles` field are the superseded parts. -> Rewriting them is runner-spike's, who owns this file. +> **Sections 4.1 through 4.3, all of section 5, and 8.0 through 8.3 describe the removed +> folder ("item mode") codec and its removed policy fields** (`on_unsupported`, +> `on_executable`, `persist_executable_capability`, and the four-layer executable split). +> A marker now resolves one file, so item mode has no caller. Section 8.4 (single-text +> mode) is the current model: read it, not 8.1 to 8.3. Rewriting 4.1 to 4.3, 5, and 8.0 to +> 8.3 into the single-marker shape is runner-spike's, who owns this file; until then, treat +> them as historical record of the folder design, not as current behavior. This contract defines how the runner reads content from its workspace and hands it to a commit. It replaces the behavior in the `skill-codec.ts` prototype. The prototype was @@ -248,6 +253,10 @@ A refused link is an unsupported entry. Section 4.2 defines what happens to it. ## 4. What the import accepts +> **Historical: describes the removed folder codec.** See the banner at the top of this +> document. A marker resolves one file; there is no folder shape, no `SKILL.md` discovery, +> and no `files[]` generation in the current model. + ### 4.1 Required shape An import folder must hold a `SKILL.md` at its top level. The file must parse as UTF-8. Its YAML @@ -325,6 +334,11 @@ success from becoming a server-side validation failure. ## 5. Executable policy +> **Historical: describes the removed folder codec's policy fields.** See the banner at +> the top of this document. `on_executable` and `persist_executable_capability` were +> removed; `executable` and `allow_executable_files` are now ordinary agent-authored +> fields the approval card shows, per `change-set.md` 6.2. + ### 5.1 Two rules 1. A filesystem fact never becomes a permission grant. @@ -725,6 +739,9 @@ approval user interface can prove which manifest the human saw. It is separate f ### 8.0 Two presentation modes +> **Item mode (8.1 to 8.3) is historical: it describes the removed folder codec.** See the +> banner at the top of this document. Single-text mode (8.4) is the current model. + The import has two source shapes, so the card has two modes. Section 5.5 defines the split. | Mode | Source | Operation | The card shows | diff --git a/docs/design/agent-config-editing/research.md b/docs/design/agent-config-editing/research.md index 695507ebcf..7620998c16 100644 --- a/docs/design/agent-config-editing/research.md +++ b/docs/design/agent-config-editing/research.md @@ -5,14 +5,19 @@ facts each slice builds on, with file references. All were verified on main, 202 ## Primary documents +These three were the working drafts that the 5 August consolidation decided. The +`contracts/` directory is now the source of truth; where a contract disagrees with a +document below, the contract wins. Read these for the reasoning history, not the current +behavior. + - `research/rfc.html`: requirements, user stories, design questions with decided - options, known defects. This is the product source of truth. + options, known defects. Historical; superseded by `contracts/`. - `research/change-set-interface-codex.md`: the delta interface. Ordered operations, structured targets, error model, base check, `value_from`, one engine with two - wrappers. Accepted as the working draft. + wrappers. Historical; superseded by `contracts/change-set.md`. - `research/runner-lifecycle-codex.md`: the runner architecture. Applied-state identity, five lifecycles, harness and provider ports, a nine-step migration path, twelve risks. - Accepted as the working draft. + Historical; superseded by `contracts/adapter-matrix.md`. ## Code facts the slices build on diff --git a/docs/design/agent-config-editing/research/rfc.html b/docs/design/agent-config-editing/research/rfc.html index ff507e842c..252e665b8c 100644 --- a/docs/design/agent-config-editing/research/rfc.html +++ b/docs/design/agent-config-editing/research/rfc.html @@ -1,3 +1,4 @@ + RFC: The agent edits its own configuration