From cc7bb619e171d38b0262c50d09a201705c266e51 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Mon, 27 Jul 2026 21:05:37 +0200 Subject: [PATCH] docs(design): park design, research and go-to-market notes Collects design and go-to-market material that was sitting uncommitted in the working tree so it survives a workspace cleanup. Design and research notes (docs/design/agent-workflows/): - selfhost-hardening plan and the runner engine-quality roadmap - research on client-tool delivery and concurrent human-in-the-loop approvals - the desloppify round-two assessment - root-cause writeups for warm-resume execution and frontend approval dispatch - the PR #5400 mounts review handoff, review report and fix brief - the retheme hex audit and the stale positioning sweep Go-to-market material (docs/design/gtm/), moved out of the repo root: - the go-to-market wiki (was gtm.txt) - the README rewrite draft (was readme.new.md) - pricing page revision 3 (was website/new-pricing.json, unreferenced by the site) - the sign-in redesign handoff with its logos and design tokens --- .../engine-quality-roadmap.md | 370 +++++++ .../projects/selfhost-hardening/plan.md | 438 +++++++++ .../debug-frontend-approval-dispatch.md | 222 +++++ .../debug-warm-resume-execution-failure.md | 210 ++++ .../scratch/desloppify-round2-assessment.md | 318 ++++++ .../pr-5400-drive-mounts-review-handoff.md | 921 ++++++++++++++++++ .../pr-5400-drive-mounts-review-report.md | 645 ++++++++++++ .../pr-5400-fix-implementation-brief.md | 514 ++++++++++ ...search-client-tools-and-concurrent-hitl.md | 259 +++++ .../scratch/retheme-hex-audit-2026-07-19.md | 220 +++++ ...ale-llmops-positioning-sweep-2026-07-23.md | 59 ++ docs/design/gtm/README-draft.md | 248 +++++ docs/design/gtm/gtm-wiki.md | 688 +++++++++++++ docs/design/gtm/pricing-page-revision-3.json | 907 +++++++++++++++++ docs/design/gtm/readme-rewrite-draft.md | 492 ++++++++++ .../gtm/signin-redesign-handoff/README.md | 103 ++ .../logos/Agenta-logo-full-dark-accent.svg | 4 + .../assets/logos/Agenta-logo-full-dark.svg | 4 + .../assets/logos/Agenta-logo-full-light.svg | 4 + .../logos/Agenta-symbol-dark-accent.svg | 3 + .../assets/logos/Agenta-symbol-dark.svg | 3 + .../assets/logos/Agenta-symbol-light.svg | 3 + .../signin-designs.html | 385 ++++++++ .../signin-redesign-handoff/tokens/colors.css | 66 ++ .../tokens/effects.css | 23 + .../tokens/typography.css | 38 + docs/design/gtm/signup-redesign-brief.md | 71 ++ 27 files changed, 7218 insertions(+) create mode 100644 docs/design/agent-workflows/projects/runner-desloppify-redo/engine-quality-roadmap.md create mode 100644 docs/design/agent-workflows/projects/selfhost-hardening/plan.md create mode 100644 docs/design/agent-workflows/scratch/debug-frontend-approval-dispatch.md create mode 100644 docs/design/agent-workflows/scratch/debug-warm-resume-execution-failure.md create mode 100644 docs/design/agent-workflows/scratch/desloppify-round2-assessment.md create mode 100644 docs/design/agent-workflows/scratch/pr-5400-drive-mounts-review-handoff.md create mode 100644 docs/design/agent-workflows/scratch/pr-5400-drive-mounts-review-report.md create mode 100644 docs/design/agent-workflows/scratch/pr-5400-fix-implementation-brief.md create mode 100644 docs/design/agent-workflows/scratch/research-client-tools-and-concurrent-hitl.md create mode 100644 docs/design/agent-workflows/scratch/retheme-hex-audit-2026-07-19.md create mode 100644 docs/design/agent-workflows/scratch/stale-llmops-positioning-sweep-2026-07-23.md create mode 100644 docs/design/gtm/README-draft.md create mode 100644 docs/design/gtm/gtm-wiki.md create mode 100644 docs/design/gtm/pricing-page-revision-3.json create mode 100644 docs/design/gtm/readme-rewrite-draft.md create mode 100644 docs/design/gtm/signin-redesign-handoff/README.md create mode 100644 docs/design/gtm/signin-redesign-handoff/assets/logos/Agenta-logo-full-dark-accent.svg create mode 100644 docs/design/gtm/signin-redesign-handoff/assets/logos/Agenta-logo-full-dark.svg create mode 100644 docs/design/gtm/signin-redesign-handoff/assets/logos/Agenta-logo-full-light.svg create mode 100644 docs/design/gtm/signin-redesign-handoff/assets/logos/Agenta-symbol-dark-accent.svg create mode 100644 docs/design/gtm/signin-redesign-handoff/assets/logos/Agenta-symbol-dark.svg create mode 100644 docs/design/gtm/signin-redesign-handoff/assets/logos/Agenta-symbol-light.svg create mode 100644 docs/design/gtm/signin-redesign-handoff/signin-designs.html create mode 100644 docs/design/gtm/signin-redesign-handoff/tokens/colors.css create mode 100644 docs/design/gtm/signin-redesign-handoff/tokens/effects.css create mode 100644 docs/design/gtm/signin-redesign-handoff/tokens/typography.css create mode 100644 docs/design/gtm/signup-redesign-brief.md diff --git a/docs/design/agent-workflows/projects/runner-desloppify-redo/engine-quality-roadmap.md b/docs/design/agent-workflows/projects/runner-desloppify-redo/engine-quality-roadmap.md new file mode 100644 index 0000000000..238911a231 --- /dev/null +++ b/docs/design/agent-workflows/projects/runner-desloppify-redo/engine-quality-roadmap.md @@ -0,0 +1,370 @@ +# Engine quality roadmap: making the sandbox-agent engine well-written + +**Date:** 2026-07-19 +**Audience:** Mahmoud first (senior architect, new to TypeScript), then the implementer +agents who will do this work. Every TypeScript-specific word is defined in one plain sentence +the first time it appears. +**Status:** design and TODO list. Read-only assessment of the code as it stands on the lane +`plan/client-tools-daytona` (PR #5383). No code was changed to write this. + +This document answers a specific complaint. Reviewing PR #5383, you looked at +`services/runner/src/engines/sandbox_agent/environment.ts` and said it is "not very well +written, probably not from that PR." You are right on both counts, and the reason is worth +stating plainly before we plan the fix, because the fix depends on understanding why the code +looks the way it does. + +A note on one word used throughout. A *module* (or *file*) here is a single `.ts` file. When +this document says a file "does too many jobs," it means the file mixes several unrelated +concerns that a reader has to hold in their head at once. + +--- + +## 1. Why the code looks the way it does + +`environment.ts` is not the product of PR #5383. Almost all of it is code that existed months +ago, moved verbatim into a new file. Here is the history. + +The runner is the Node service that drives a coding agent inside a sandbox. (Node is a runtime +for running JavaScript and TypeScript on a server.) Its core used to be one file, +`sandbox_agent.ts`, that had grown to about 2,477 lines and did everything: plan the run, +acquire the sandbox, mount storage, wire up tools, open the agent session, run each turn, and +tear it all down. + +In July we ran a "desloppify" pass on that one file. The shipped work, PR #5369 (in release +v0.105.5), was deliberately **Phase 1 only: pure decomposition, moves and nothing else.** The +rule for that pass was strict and correct: change *where* code lives, never *what it does*. +Every test that passed before had to pass after, the network messages had to stay byte-for-byte +identical, and any bug found while moving code was written down and left in place, not fixed. +That is why the 2,477-line monolith became a 44-line facade (a *facade* is a small file whose +only job is to re-export names from sibling files, so the rest of the codebase has one stable +place to import from) plus about ten sibling files, one of which is `environment.ts`. + +The value Phase 1 bought is real and should not be undersold: the giant file is gone, each +concern now has a named home, the eight extraction commits each stayed green so the history +bisects cleanly, and the whole thing survived the agent release gate (the wire-level end-to-end +QA harness) across both harnesses and both sandboxes. It also survived two later rebases (onto +v0.105.4 and JP's sessions work) without behavior drift. That is a solid foundation. + +But Phase 1 was explicitly the *first* of three phases, and the other two were deferred with +written designs. The restructuring that would make each file actually *well-written* (grouping +files into subsystems (Phase 2) and drawing clean seams so the code stops asking "is this Pi? is +this Daytona?" at every step (Phase 3)) was never done. So `environment.ts` today holds the old +`acquireEnvironment` function body, roughly 1,100 lines, almost exactly as it was inside the +monolith. It was moved, not rewritten. Your instinct that it is "probably not from that PR" is +precisely correct: PR #5383 (client tools on Claude in Daytona) touched only a handful of lines +inside it; the shape you dislike is inherited. + +This document takes the code as it is now and lays out how we make it good, in follow-ups that +each stand alone. + +--- + +## 2. An honest per-file assessment of the engine as it stands + +The engine lives in `services/runner/src/engines/sandbox_agent/`. It is about 40 files. Most are +small, single-purpose, and fine. The assessment below names only the files that carry debt; the +unnamed ones (for example `pause.ts`, `usage.ts`, `errors.ts`, `capabilities.ts`) are healthy. + +### 2.1 `environment.ts` (1,077 lines): the one that reads badly, dissected + +This file exports `acquireEnvironment`, the function that builds everything a session needs +before the first turn runs. The problem is not its length on its own. The problem is that one +function interleaves at least eight distinct jobs, threads a single mutable object through all of +them, and branches on the harness type and sandbox type at nearly every step. Here are the eight +jobs and roughly where each one lives in the function today: + +| Job | What it does | Where it lives now | +|---|---|---| +| Preflight gating | Throw before any resource if a fail-closed condition holds (model config unwritable, permission extension missing) | lines 566–580 | +| Local mount wiring (pre-start) | Mount the durable working directory and agent directory on the host before the daemon spawns | lines 588–596 | +| Sandbox acquisition | Build the provider, reconnect a stored sandbox by id or create a fresh one | lines 597–665 | +| Asset preparation | On Daytona, upload the harness login, the Pi extension, and the in-sandbox tool shim | lines 667–703 | +| Remote mount wiring | On Daytona, mount the durable cwd, the per-harness session dirs, and the agent dir over the sandbox filesystem | lines 705–802 | +| Session init and continuity/hydration | Build the session-init payload, hydrate the prior native session id from durable storage, resume-by-id or cold-create the session | lines 804–848, 901–1014 | +| Model + capability resolution | Probe what the harness supports, assert required capabilities, resolve the requested model | lines 850–879, 1016–1036 | +| Listener attachment | Attach the session-lifetime event and permission listeners that demux into the active turn | lines 1038–1049 | + +On top of those eight jobs, the function defines its own **teardown closure** (a *closure* is a +function defined inside another function that can still read the outer function's variables; +`environment.destroy`, lines 288–366) and five more closures for local remount-after-disconnect +recovery (lines 368–563), all before the real work even begins in the `try` block at line 565. + +Three things make this shape costly, and they are worth spelling out because they are the whole +argument for the roadmap: + +1. **Every new feature threads through this one function.** PR #5383 is the illustration: adding + client tools on Daytona meant editing the asset-preparation job (line 691, upload the full + tool set) and reading through the mount, session, and MCP jobs to be sure the change was safe. + The blast radius of any change is the whole function. + +2. **Testing means mocking the world.** Because the eight jobs share one function and one mutable + object, there is no way to test "does sandbox acquisition fall back to a fresh create when + reconnect throws?" without standing up fake versions of the mount signer, the provider, the + workspace preparer, the capability probe, the persist driver, and the session. The dependency + injection is already there (the `deps` argument lets a test pass fakes), but the unit under + test is the entire acquire, not the one job. + +3. **Two people cannot work in it at once.** A change to continuity and a change to mount wiring + both edit the same 1,100-line function, so they collide. This is not hypothetical; the HANDOFF + already records cross-lane friction on exactly these files. + +This is the file behind your inline comment on line 712 (`environment.ts:712`, the +`if (environment.mountCreds && plan.isDaytona)` branch). Your words were: "is `pi` and `is` at +each place not very good programming... the problem is there is some sort of port adapter for +dealing with the logic, where you don't have to have if-then-else for the harness type within the +code. It does not look like the environment TS should know about that. Responsibility is all over +the place." That is exactly the diagnosis. `acquireEnvironment` asks `plan.isDaytona` or +`plan.isPi` at roughly a dozen sites, and each branch is a place where provider-specific or +harness-specific knowledge has leaked into a function that should be provider-agnostic and +harness-agnostic. + +### 2.2 `environment-setup.ts` (388 lines): the setup prefix, decent but doing planning inline + +Phase 1 carved `prepareEnvironmentSetup` out of the middle of `acquireEnvironment`. It runs +first and builds the immutable run plan, the daemon environment, the fail-closed flags, and the +empty accumulator object that `acquireEnvironment` then fills. This extraction was the right +move and the file is more coherent than `environment.ts`. But it still bundles several planning +concerns inline: mount signing, model-config planning for OpenAI-compatible custom providers +(lines 228–247), local Pi asset preparation with its fail-closed flags (lines 250–275), and the +construction of a 40-field accumulator object (lines 313–355). It returns a 30-key bundle by +`destructuring` (unpacking many named fields from one object in a single statement), which +`acquireEnvironment` immediately unpacks. That wide bundle is a symptom, not a bug: it is the +seam where one giant function was cut in half, and the two halves still share a lot of state. + +### 2.3 `run-turn.ts` (640 lines): long but genuinely one job + +`runTurn` runs a single turn against an already-acquired environment. It is long, but unlike +`acquireEnvironment` it does one thing, and its length comes from the real complexity of a turn: +run-limit deadlines, the pause controller, permission responders, the client-tool relay, the +prompt-versus-pause race, usage resolution, and continuity bookkeeping. It reads far better than +`environment.ts`. It is not a priority target. The one note is that the resume path (lines +439–483) and the fresh-prompt path share a lot of structure and could tighten later, but this is +polish, not debt. + +### 2.4 `mcp.ts` (363 lines): clear, with two deliberate layers + +`buildSessionMcpServers` builds the list of MCP servers (MCP is the protocol a harness uses to +discover and call tools) for a session. Its header is explicit that it holds two independent +layers that must never be merged: the internal Agenta tool channel and the user's own declared +servers. The file is well-documented and the `isDaytona` branching (lines 330–343) is the same +provider leak as `environment.ts`, but confined and explained. It is a natural client of the +future tool-delivery port (see §3.4), not a standalone cleanup target. + +### 2.5 `client-tools.ts` (307 lines): the closed set you flagged as "weird" + +This file holds the shared client-tool seam and, at lines 37–64, the `ClientToolPauseDisposition` +type: a closed set of three named string values, `"pi-native"`, `"cold-acknowledge"`, and +`"warm-hold"`. (A closed set like this is a *discriminated union*: a value that must be exactly +one of a fixed list of strings, and the compiler forces code to handle every case.) Your inline +comment on line 44 was: "quite weird structuring... it feels very weird to have pi-native, +cold-acknowledge, and warm-hold at this position. The right way is to have maybe a port and then +a pi version and a version for others. I wouldn't call this good code." + +The critique is fair, and it is the same critique as the `environment.ts:712` one, wearing +different clothes. `ClientToolPauseDisposition` is a small, honest stand-in for a proper seam. It +puts the three pause behaviors in one exhaustive switch so that adding a fourth forces a +compile-time decision rather than silently falling through. That is a real improvement over an +`!isPi` test scattered across call sites, and it is why the closed set was introduced. But it is +still a per-harness behavior modelled as a string enum that the run plan carries, rather than a +method on a harness object that owns its own pause behavior. The clean shape you named, a port +with a Pi implementation and a non-Pi implementation, is precisely the harness and tool-delivery +ports of Phase 3. The closed set is the bridge to it, not the destination. + +### 2.6 `run-plan.ts` (544 lines): a long builder, mostly fine + +`buildRunPlan` turns a request into an immutable plan and holds all the fail-closed admission +gates (network policy, filesystem policy, code tools, remote tool delivery, reserved MCP names). +It is long because it is the one admission point and every gate has a named error message. It +reads well. Its `RunPlan` interface (lines 94–166) is where new booleans accumulate, and the +Phase 3 port design explicitly names "boolean accretion on the run plan" as an anti-goal, so this +file is a client of the ports rather than a cleanup target on its own. + +--- + +## 3. The improvement path, as ordered follow-ups + +The path has four parts, ordered so each one stands alone, ships behind the unit suite plus the +agent release gate, and makes the next one cheaper. The first part is new design work and is +sketched in full below; the rest consolidate designs that already exist. + +### 3.1 First: split `acquireEnvironment` into named pipeline stages (NEW design) + +This is the single highest-value change for the file you flagged, and it is a prerequisite for +the ports. The idea is to stop expressing acquire as one 1,100-line function with a shared +mutable object, and express it instead as a short, linear pipeline of named stages, each with an +explicit input and an explicit output. + +**The state object that flows through.** Define one context object, `AcquireContext`, that holds +the immutable inputs (`request`, `deps`, `signal`, the built `plan`, the daemon `env`, the +`logger`, the timing logger) and the accumulator `environment` that stages fill in. Today that +accumulator already exists as `SessionEnvironment`; the change is that each stage receives the +context and returns a typed, named slice of new state, rather than every step reaching into one +blob and mutating whatever it likes. A stage's signature becomes the honest declaration of what +it reads and what it produces. + +**The stage list.** Reading top to bottom, `acquireEnvironment` becomes this pipeline. Each stage +is one function in its own small file (or grouped, see §3.2): + +| Stage | Input it reads | Output it produces | +|---|---|---| +| `planRun` (exists as `prepareEnvironmentSetup`) | `request`, `deps` | `plan`, daemon `env`, fail-closed flags, empty `environment` | +| `assertPreflight` | the fail-closed flags | throws, or nothing (pure, no I/O) | +| `mountLocalPreStart` | `plan`, mount creds | `environment.mountedCwd`, `environment.agentMountedPath` (local only) | +| `acquireSandbox` | `plan`, `env`, start options | `environment.sandbox`, `resumable`, in-flight registration | +| `deliverRemoteAssets` | `plan`, `sandbox` | `internalToolMcp` (Daytona only) | +| `wireRemoteMounts` | `plan`, `sandbox`, mount creds | remote cwd, session-dir, and agent mounts (Daytona only) | +| `materializeWorkspace` | `plan`, `sandbox` | `environment.workspace`, Pi session dir | +| `probeCapabilities` | `plan`, `sandbox` | `environment.capabilities` (and the required-capability assertion) | +| `buildToolDelivery` | `plan`, `capabilities`, `internalToolMcp` | session MCP servers, `environment.closeToolMcp` | +| `openSession` | session-init payload, continuity store, `persist` | `environment.session`, `sessionId`, `loadedFromContinuity` | +| `resolveModel` | `session`, wanted model | `environment.model` | +| `attachListeners` | `environment` | session-lifetime `onEvent` / `onPermissionRequest` wired | + +The teardown closure and the local-remount closures move out of the function body into a small +`EnvironmentLifecycle` object built once from the context, so the pipeline driver reads as a +dozen named calls instead of thirteen hundred lines. A mid-pipeline failure still runs the one +idempotent teardown, exactly as today. + +**What tests each stage gets.** This is the payoff. Each stage is an async function taking a +context and its injected dependencies, so each is unit-testable in isolation against fakes, +without booting the whole acquire: + +- `acquireSandbox`: fake `startSandboxAgent` that throws on the reconnect id, assert it falls + back to a fresh create and still registers the in-flight handle. +- `wireRemoteMounts`: fake `mountStorageRemote` that reports the store unreachable, assert the + stage skips the mount and leaves the cwd unmounted rather than proceeding as if mounted. +- `openSession`: fake `persist` and `sandbox`, assert resume-by-id is attempted when an eligible + prior session id exists and that a `resumeSession` throw degrades to a cold `createSession`. +- `assertPreflight`: pure, assert each fail-closed flag throws its named message. +- `deliverRemoteAssets`: fake sandbox, assert the tool shim is uploaded only for the non-Pi + Daytona tools case and that a failed upload throws (fail loud). + +None of these tests exists today, because the unit under test is the whole function. + +**Size and behavior guarantee.** This is an internal refactor of one file. It changes no wire +behavior and is fully gated by the existing unit suite plus new per-stage tests. It is +independent of the folder grouping and the ports, and it makes both of them dramatically easier, +because a stage is a far smaller thing to move into a subfolder or to hand a capability object +than a region of a mega-function. + +### 3.2 Second: Phase 2 folder grouping (existing design, one fix first) + +The HANDOFF already specifies grouping `sandbox_agent/` into `environment/`, `session/`, `turn/`, +and `tools-delivery/` subfolders, each with an `index.ts` public entry, leaving the facade, +`engine.ts`, `runtime-contracts.ts`, `runtime-policy.ts`, and `errors.ts` at the top level. + +**The one blocker to clear first is `daemon.ts`.** It computes its own package root by walking +three directory levels up from its own file location +(`dirname(dirname(dirname(fileURLToPath(import.meta.url))))`). That root drives where the harness +binaries are found at runtime. Moving `daemon.ts` one level deeper silently changes the root and +breaks binary resolution in a real deployment, and the unit tests do not exercise real binary +resolution, so typecheck and the unit suite would both stay green while the runtime broke. The +fix is to make root resolution location-independent (resolve from a known anchor rather than a +fixed number of `dirname` hops) so the file can move safely. The two cross-lane blockers the +HANDOFF also named (pi-openai and the run-plan fight) have since landed, so grouping is otherwise +unblocked. + +Grouping is behavior-safe and test-gated for every file except `daemon.ts`. Done after the +pipeline split, the subfolders fall out naturally: the stage files from §3.1 are the contents of +`environment/`. + +### 3.3 Third: Phase 3 provider port (existing design) + +`port-design.md` §Seam 1 specifies turning the scattered "is it Daytona / is it remote" checks +into one provider object per backend that *declares* its traits (is-remote, working-directory- +is-a-FUSE-mount, can-enforce-network-policy, can-inject-runtime-credentials, who-installs-the- +harness-binary, and its keepalive style), read at one dispatch point. After the pipeline split, +each provider check is confined to a single stage (`mountLocalPreStart`, `acquireSandbox`, +`wireRemoteMounts`, `deliverRemoteAssets`), so replacing each `plan.isDaytona` branch with a +capability read is a local, testable change inside one stage rather than surgery across a +mega-function. This is the first half of the answer to your `environment.ts:712` comment. + +### 3.4 Fourth: Phase 3 harness and tool-delivery ports (existing design) + +`port-design.md` §Seam 2 and §Seam 3 specify the harness port (credentials as env keys plus an +optional credential file, asset preparation dispatched in one place keyed on harness id, model +naming and capability flags per harness) and the tool-delivery port (the three delivery +mechanisms behind one interface, with the deliverability policy computed in one place). These two +ports are the second half of the answer to `environment.ts:712` and the whole answer to your +`client-tools.ts:44` comment. The `ClientToolPauseDisposition` closed set becomes a method on the +harness (or tool-delivery) object: Pi's implementation parks natively, the non-Pi shim +implementation writes the acknowledging answer. Adding a harness becomes "write one object," +which is the shape you asked for. + +### 3.5 In parallel: the round-2 shell fixes (existing assessment) + +The round-2 assessment (`desloppify-round2-assessment.md`) already inventories the rest of the +runner, outside this engine. Those fixes are independent of the engine work and can run in +parallel: split the 1,601-line `tracing/otel.ts` into five files, extract the keepalive dispatch +out of `server.ts`, split `tools/relay.ts`, introduce a shared logger and error-formatter, hoist +the duplicated MCP request/response envelope into one file shared by both transports, unify the +tool-kind dispatch between `tools/relay.ts` and `tools/dispatch.ts`, and move `session-pool.ts` +and `session-identity.ts` into `src/sessions/`. One of these, the shared MCP envelope, connects +to the tool-delivery port in §3.4 and is best sequenced just before it. + +### 3.6 Where your PR #5383 comments land in this sequence + +You left seven comments on the PR. Their dispositions: + +- **`environment.ts:712`** (the `is pi` / `is daytona` branching, "responsibility all over the + place"): the central structural critique. Addressed by §3.1 (the pipeline split isolates each + branch into one stage) and completed by §3.3 and §3.4 (the ports replace each branch with a + capability or harness-method read). Not a quick fix; it is the spine of this roadmap. +- **`client-tools.ts:44`** ("quite weird structuring... the right way is a port and a pi version + and an others version"): the same critique for the pause behavior. Addressed by §3.4 (the + disposition becomes a harness/tool-delivery port method). Structural debt, not a quick fix. +- **`PLAN.md:343`** ("I think we need to solve this today, no?" on identical-argument + correlation): a feature-QA decision, not roadmap debt. It was resolved by your own plan-review + decision #2: the live QA must force two client tools with identical name and arguments in one + turn, and only if a widget attaches to the wrong call do we add the shim's request id as a + correlating hint, as its own small commit. Actionable inside the feature PR's QA, already + decided; carry the "add the id hint only if the forced test fails" as a small optional + follow-up. +- **`PLAN.md:353`** ("I thought we really paused... why can't we use the native pausing behavior, + I'm lost"): a design question about why Claude on Daytona gets a written "waiting" answer + instead of a true pause. The answer is that Claude's in-sandbox shim blocks on a relay answer + file and has no native cross-file-relay pause channel the way Pi's extension does, so the + acknowledging answer is the only way to end its blocking call cleanly today. The true native + hold is the reserved `"warm-hold"` disposition, tracked as #5384, and it becomes clean to build + once the tool-delivery port exists (§3.4). Lands there. +- **`relay.ts:574`, `client-tools.ts:55`, `dispatch.ts:54`** (the three "Reviewer note ..." + comments): these are your own author annotations explaining and defending the design (the + pause/teardown ordering, why the closed set lives at the client-tool boundary, why the + dispatch throw is defensive). No action; they document intent and should stay as comments. + +Two non-blocking CodeRabbit comments on the PR are unrelated to engine quality: a `revertable` +to `revertible` spelling fix in `PLAN.md`, and a suggestion to wrap relay unit-test bodies in +`try/finally` so a failed assertion still stops the relay. Both are small and belong to the +feature PR, not this roadmap. + +--- + +## 4. TODO table + +Each row is a standalone follow-up. Size is in agent-hours: S is under a day, M is one to two +days, L is more than two days. Every row is verified by the existing unit suite plus, for the +runtime-touching ones, the agent release gate. + +| # | Follow-up | Scope | Size | Prerequisite | Design doc | +|---|---|---|---|---|---| +| 1 | Pipeline-stage split of `acquireEnvironment` | Refactor `environment.ts` into ~12 named stages + one `AcquireContext`; lifecycle/remount closures become a small object; add per-stage unit tests | M | none (Phase 1 shipped) | this doc §3.1 (new) | +| 2a | `daemon.ts` package-root fix | Make binary-root resolution location-independent so the file can move | S | none | HANDOFF finding 1 | +| 2b | Phase 2 folder grouping | Group `sandbox_agent/` into `environment/` `session/` `turn/` `tools-delivery/`, each with `index.ts`; re-point imports | M | 1, 2a | HANDOFF §Phase 2 | +| 3 | Phase 3 provider port | One provider object per backend declaring its traits; single dispatch; each `isDaytona` branch becomes a capability read | L | 1, 2b | port-design §Seam 1; answers `environment.ts:712` | +| 4 | Phase 3 harness port | Harness object owning credentials, asset prep, model naming; removes `isPi` branching | L | 3 | port-design §Seam 2; answers `environment.ts:712`, `client-tools.ts:44` | +| 5 | Phase 3 tool-delivery port | Three delivery mechanisms behind one interface; deliverability policy in one place; `ClientToolPauseDisposition` becomes a harness/port method | M–L | 4 | port-design §Seam 3; answers `client-tools.ts:44` | +| 6 | Warm-hold client-tool disposition (#5384) | Build the reserved native-hold pause for the non-Pi shim | M | 5 | answers `PLAN.md:353` | +| 7 | Identical-args correlation hint (optional) | Add the shim's request id as a correlating hint, only if the forced live QA shows a widget on the wrong call | S | feature QA | plan-review decision #2; answers `PLAN.md:343` | +| S1 | Split `tracing/otel.ts` into 5 files | `trace-export`, `span-attributes`, `pi-extension-tracer`, `sandbox-agent-tracer`, `startup-banner` | L | none (parallel) | round-2 §telemetry | +| S2 | Extract keepalive dispatch from `server.ts` | Move the ~600-line keepalive subsystem to `sessions/keepalive-dispatch.ts`; re-point its tests | M | none (parallel) | round-2 §server | +| S3 | Split `tools/relay.ts` | Pull filesystem adapters into `relay-host.ts`, move redaction helpers out, type `sandbox: any` | M | none (parallel) | round-2 §relay | +| S4 | Shared logger + `errText` helper | One `makeLogger(prefix)` and one `errText(err)`; delete 8 hand-rolled loggers and ~29 repeated error slices | S | none (parallel) | round-2 §duplication 1 | +| S5 | Hoist the MCP envelope | One shared request/response envelope builder for both tool-call transports | S–M | none; sequence before 5 | round-2 §duplication 2 | +| S6 | Unify tool-kind dispatch | Collapse the drifted copy in `tools/relay.ts` into `tools/dispatch.ts` | S | S5 | round-2 §duplication 3; note `dispatch.ts:54` | +| S7 | Move session pool + identity to `src/sessions/` | Relocate `session-pool.ts` and `session-identity.ts` out of the engine folder | M | 2b | round-2 §sessions-in-two-places | +| S8 | Small nits + 2 test gaps | Typed-config reads, dead `void err`, `tools/tool-mcp-http.ts` and `sessions/auth.ts` tests | S | none | round-2 §layer-one list 8–9 | + +Rows 1 through 6 are the engine-quality spine and run in order. Rows S1 through S8 are the +surrounding-runner shell fixes from the round-2 assessment and run in parallel, except S5 is best +sequenced just before row 5 (the shared envelope feeds the tool-delivery port) and S7 follows the +grouping in row 2b. diff --git a/docs/design/agent-workflows/projects/selfhost-hardening/plan.md b/docs/design/agent-workflows/projects/selfhost-hardening/plan.md new file mode 100644 index 0000000000..695b3fb51b --- /dev/null +++ b/docs/design/agent-workflows/projects/selfhost-hardening/plan.md @@ -0,0 +1,438 @@ +# Self-hosting hardening plan + +## What this is + +Two things landed at once and point at the same area: + +1. GitHub issue [#5401](https://github.com/Agenta-AI/agenta/issues/5401): a self-hoster + followed the quick-start guide and `docker compose up` failed because + `ghcr.io/agenta-ai/agenta-runner:latest` does not exist on the registry. +2. Field feedback from a second self-hoster who hit a string of problems setting up the OSS + stack with the agent runtime. + +Every item below was verified against the current code on 2026-07-20, not taken on trust +from the reports. The feedback was about a week old, so several bugs were already fixed in +the meantime. Each item says whether it is still real, already fixed, or by design, with the +file and line that decides it. + +The items are sorted into three groups: + +- **Part 1:** problems that belong to self-hosting and that we will fix. +- **Part 2:** problems that are already fixed in code, where the only remaining issue is a + self-hoster running an old runner image. +- **Part 3:** separate bugs that are not about self-hosting and belong to other teams. + +Open decisions that need your call are collected at the end, under "Decisions for you." + +--- + +## Part 1: Self-hosting problems we will fix + +### 1. Runner image `agenta-runner:latest` is never published (issue #5401) + +**Status: still present. Confirmed.** + +One workflow in the private `agenta_cloud` repo, `43-release-to-ghcr.yml`, is the only thing +that builds the public OSS images and writes the `latest` tag. It has two job lists +(`build-internal` at lines 58-61 and `merge-and-mirror` at lines 162-165), and both name +only `agenta-api`, `agenta-web`, and `agenta-services`. The runner was never added, so it is +never built for the public registry and never tagged. The build script and image-naming +logic already handle the runner, and the runner's Dockerfile +(`services/runner/docker/Dockerfile.gh`) exists and is tracked, so nothing else in the +pipeline needs to change. + +The mirror step already writes both the version tag and `latest` in one call, so adding the +runner publishes `agenta-runner:vX.Y.Z` (which Helm pulls) and `agenta-runner:latest` (which +Docker Compose pulls) together. + +The `agenta-completion` and `agenta-chat` images that look like a missing pattern are old +leftovers from before those services merged into `agenta-services`. Nothing republishes them. +They are a red herring. + +**The fix is two lines: add `agenta-runner` to both job lists.** That is the whole change. +There is no error in our CI today, because the workflow does not touch the runner at all +right now. The 404 the user saw happens later, on their own `docker compose up`, not inside +our pipeline. The image publishes on the next release, which is expected within hours. We are +not doing a one-time manual publish; waiting for the next release is fine. + +(We are not adding any guard or extra safety step to the workflow. One edge case exists in +theory: if a future release branch were missing the runner's build file, the build step would +quietly pass while the publish step failed with an unclear message. That build file is +permanently tracked and always included, so the case does not arise. If we ever want a clearer +message for it, we can add one later; it is not needed now.) + +**Areas:** CI only. Compose and Helm need no change; both start working the moment the image +exists. + +--- + +### 2. Empty `AGENTA_STORE_REGION` makes agent runs hang forever + +**Status: still present. Confirmed. Decided by `api/oss/src/utils/env.py:1093`.** + +**Context first: what the "store" is, and why even a local setup needs a region.** +Agenta has an object store, which is an S3 bucket that holds each agent run's durable +working directory. When an agent runs, it works inside a throwaway sandbox, but it needs a +folder that survives across turns and across sandboxes. That folder lives in the store and is +mounted into the sandbox over the network. If the store cannot answer, the run cannot get its +working folder, so it hangs. That is why this bug freezes runs rather than breaking some minor +feature. + +The setting is called "store region," not "S3 region," on purpose. The store is an +abstraction that runs on different S3-compatible backends: a bundled open-source server called +SeaweedFS for local self-host, and real Amazon S3 or Cloudflare R2 in cloud. All of them speak +the S3 protocol through one code path (`api/oss/src/core/store/storage.py:55-61`, +`api/oss/src/utils/env.py:1082`). This matters for the common belief that "local self-hosters +do not use S3." They do. The local backend is SeaweedFS, which is an S3 server. An S3 client +must be handed a region to sign its requests, even when the backend ignores the value. So a +local self-host needs a region too. For SeaweedFS the exact value does not matter; it only has +to be non-empty, and `us-east-1` is the safe filler. + +**The bug.** The API falls back to `us-east-1` only when the variable is completely unset +(`os.getenv("AGENTA_STORE_REGION", "us-east-1")`, `api/oss/src/utils/env.py:1093`). But every +OSS `gh` compose file injects it as an empty string (`AGENTA_STORE_REGION=${AGENTA_STORE_REGION:-}`, +`docker-compose.gh.yml:64` and `:265`, plus `gh.ssl.yml:261` and `gh.local.yml:242`). An empty +string counts as "set," so the fallback never fires, the S3 client gets an empty region, and it +refuses every request (`MissingRegion`). Helm is not affected; its template uses +`default "us-east-1"`, which treats empty as unset (`_helpers.tpl:870`). + +**The fix (do both, defense in depth):** +- Code (`api/oss/src/utils/env.py:1093`): change to + `region: str = os.getenv("AGENTA_STORE_REGION") or "us-east-1"` so an empty string is + treated as unset. This matches the sibling fields that already use the `or` idiom. +- Compose: change the four `${AGENTA_STORE_REGION:-}` occurrences to + `${AGENTA_STORE_REGION:-us-east-1}`. + +**The change adds a fallback; it does not remove one.** Today the code falls back on unset. +After the change it also falls back on empty. A real region value flows through untouched, so +nothing that works today can break. The only inputs whose behavior changes are empty and +unset, and both are already broken. + +**What to check before shipping (the cloud-safety question).** Cloud talks to real S3 or R2, +where the region is not cosmetic; it must match the bucket's actual region for request signing +to succeed. Our repo cannot see cloud's secrets. Before shipping the code change, confirm in +the private cloud repo that the cloud deployment sets a non-empty `AGENTA_STORE_REGION` that +matches the real region of the cloud store bucket. If cloud already sets a correct region, this +change does nothing to cloud. If cloud currently relies on empty or unset, that is a +pre-existing latent problem, and this is the moment to set the correct region there. + +**Areas:** API code + compose. Manual pre-ship check in the private cloud repo. + +--- + +### 3. Runner replica-id churn causes "not the owner" errors on restart + +**Status: still present. Confirmed. Decided by `services/runner/src/sessions/alive.ts:31-32` +and `services/runner/src/engines/sandbox_agent/session-continuity.ts:157-210`.** + +**Context first: what a replica id is and why the ownership check exists.** +The runner is the service that actually runs agent code. A "replica id" is the identity of one +running runner container. It is set once when the container starts, from +`AGENTA_RUNNER_REPLICA_ID` if present, otherwise a fresh random value +(`services/runner/src/sessions/alive.ts:31-32`). + +Why it matters comes down to where an agent's work physically lives. There are two sandbox +providers. With the **local** sandbox (the default), the agent's process and working directory +live inside the runner container itself. With **Daytona**, the agent runs in a separate cloud +machine. So a local session's live state exists on exactly one runner container and can only be +resumed there. If it were resumed on a different container, that container would have none of +the state and would silently start over on the wrong machine. To prevent that, the runner +records which replica owns each local session (in Redis) and refuses to resume a local session +anywhere else. The refusal is deliberate and loud: "local sandbox requires a single runner: +replica X is not the owner of session Y ... Refusing to cold-start on the wrong host" +(`session-continuity.ts:157-177`). Daytona sessions do not have this constraint, because any +runner can reconnect to the same remote machine by its stored id +(`sandbox-reconnect.ts:1-38`). + +**The bug.** The owner record in Redis has a two-minute time-to-live +(`AGENTA_SESSIONS_REDIS_OWNER_TTL_SECONDS`, default 120, `api/oss/src/utils/env.py:1298`). When +the runner restarts with no `AGENTA_RUNNER_REPLICA_ID` set, it picks a new random id. The old +owner id is still in Redis for up to two minutes, so the restarted container does not recognize +its own sessions and refuses them until the record expires. No OSS compose file sets the +variable, so self-hosters get the random path. This is exactly what the user saw. + +**The single-runner fix (compose only), and why it is safe.** OSS compose runs exactly one +runner container. If we give that container a stable id, a restart keeps the same id, the owner +record still matches, and sessions resume immediately with no two-minute gap. A fixed literal +is safe here precisely because there is only one container, so "every session is owned by +`local-runner-1`" is always true. Add to the runner service environment in the OSS `gh` compose +files: +```yaml +AGENTA_RUNNER_REPLICA_ID: ${AGENTA_RUNNER_REPLICA_ID:-local-runner-1} +``` +The `${...:-local-runner-1}` form still lets an operator override it. This matches the user's +own fix. + +**The multiple-replicas question (the honest answer).** If a self-hoster wants to run more than +one runner container with the local sandbox, a stable id does not solve their problem, and +neither would a distinct id per container. The reason is that there is no "sticky routing" in the system. When the platform +calls the runner, it uses a single address and lets the load balancer pick any container +(`services/oss/src/agent/config.py:51-54`). Nothing routes a session back to the specific +container that owns it. The owner record is only used to detect a wrong-container attempt and +fail loudly; it is never used to steer the request to the right container. So with several +local-sandbox runners behind one address, the load balancer will regularly send a session's +next turn to a container that does not own it, and the runner will correctly refuse it. + +The honest conclusion: **the local sandbox is single-runner only, by design. Scaling to +multiple runner containers requires Daytona**, whose sessions carry no container-local state +and can be resumed by any runner. This also matches the concept docs, which say "a deployment +has one logical runner" (`concepts/02-how-agents-run.mdx:22`). + +**Two related things worth flagging:** + +1. **The Kubernetes chart has the same restart problem, even with a single runner.** Helm does + not set `AGENTA_RUNNER_REPLICA_ID` at all, and it uses a Deployment, so a pod restart also + gets a new id and refuses local sessions for up to two minutes. The compose fix does not + touch Helm. See "Decisions for you" for whether we fix Helm now or document the limitation. +2. **The configuration docs describe this variable wrongly.** The reference page calls + `AGENTA_RUNNER_REPLICA_ID` a "replica identity for logs" + (`docs/docs/self-host/reference/01-configuration.mdx:201`). It is not a log label; it drives + local session ownership, and when it churns it breaks session resume. We should correct that + line and add a short note that the local sandbox is single-runner only and that scaling + needs Daytona. + +**Areas:** compose (OSS `gh` variants) + docs. Possible Helm change, pending your decision. + +--- + +### 4. Claude subscription login dies about once a day + +**Status: still present. It is a docs-and-compose gap, fixable with no image rebuild. Decided by +`docs/docs/self-host/agents/01-use-your-own-subscription.mdx:48-75` and +`services/runner/docker/Dockerfile.gh:81`.** + +**Context first: what is shared, and why the copy dies.** To run agents on a personal Claude +subscription instead of an API key, the self-hoster gives the runner container access to their +Claude Code login. That login is a token that rotates: every time Claude Code refreshes, the +provider issues a new token and retires the old one. + +The key fact is that sharing the real login file is fine, but copying it is not. Two programs that +read and write the same login file stay in sync. When one refreshes, it writes the new token to +that one file, and the other reads it from the same file next time. This is exactly why you can run +two Claude Code sessions on one machine and nothing breaks. A read-write bind mount of the real +login folder is just "the same file," so the desktop and the container coordinate the same way. + +A copy is different. Copying the login makes a second, separate file. When the desktop later +refreshes, it writes the new token to the original file, and the provider retires the old one. The +container's copy still holds the old token and never sees the new one, because it is a different +file. So the copy drifts out of sync and dies, usually within a day. This is the failure the user +hit, and it only happens with a copy. + +**Why anyone copies instead of sharing: the user-id problem.** On Linux, every account has a number +called a user id, or uid. The first human account created on a machine gets 1000. The runner +container runs as an account that is uid 1000 (`services/runner/docker/Dockerfile.gh:81`). The +Claude login file is private, readable only by its owner, and a bind mount keeps the file's +original numeric owner. So the container can read your mounted login only when your host account is +also uid 1000. On a plain single-user Linux box it usually is, and sharing the real file just +works. When your host account is not uid 1000 (you are a second user on the machine, or you run as +root or a service account, or you use certain cloud images), the container cannot read the private +file, so today's docs tell you to copy it and re-own the copy to uid 1000. That copy is the thing +that dies. + +**The fix: let every user share the real login, with no rebuild.** For a self-hoster whose uid is +not 1000, run the container as their own user at startup. This is a runtime override in the compose +file, so it needs no image rebuild. Two lines, added to the same runner override where the login +mount already goes: + +```yaml +services: + runner: + user: "1001:1001" # your own uid:gid, from `id -u` and `id -g` + environment: + HOME: /tmp + CLAUDE_CONFIG_DIR: /agenta/harness/claude + volumes: + - ~/.claude:/agenta/harness/claude:rw +``` + +The `HOME: /tmp` line is needed because Docker sets the home directory to an unwritable path when +you run as a non-default user, and the agent writes working files under its home directory. +Pointing home at `/tmp` fixes that. + +This was tested against the real runner image, not reasoned about. As uid 1000 with a login owned +by a different user, the container is denied. With the override, the container reads the login, +rewrites it on a token refresh, and the files it creates come out owned by the host user, so the +desktop keeps full access. The runner booted as the foreign user and returned a healthy status. The +one baked folder the container cannot write as a foreign user (`/pi-agent`) does not matter, because +subscription runs write into the mounted login instead, and the image was already built to run as a +foreign user. + +We considered and rejected two alternatives: changing the login file's group reintroduces the drift +problem in reverse (files the container creates become unwritable by the desktop), and a +file-access-control-list trick silently breaks when the login file is set back to private. + +**Recommendation.** Replace the copy method in the docs. Tell users: mount your real login folder +read-write. If your uid is 1000, that is all you need. If it is not, add the `user:` and `HOME` +lines above. No copy, no separate login, no rebuild. Correct the doc's "never log in again by hand" +claim, which is only true once you are sharing the real file rather than a copy. + +**Fix (docs + skill):** +- `docs/docs/self-host/agents/01-use-your-own-subscription.mdx`: replace the copy method with the + `user:`/`HOME` override for non-1000 users; keep the direct read-write mount for uid 1000; + correct the "never log in again" claim. +- Self-host skill `resources/troubleshoot.md`: add an entry for the daily authentication failure + that points at sharing the real file plus the uid override (the existing entry only covers the + first-time permission error, not the daily death). + +**One step to confirm in live QA.** The Docker test proved the container reads and rewrites the +mounted login as a foreign user, but it could not run a full agent with a real Claude login, which +needs a live subscription. The only unproven step is the harness downloading its own CLI as a +foreign user, and that step writes only to the home directory and `/tmp`, both of which tested as +writable. We should confirm it once during the live QA pass before calling this done. + +**Areas:** docs + skill. Compose override is user-side; no image or repo compose change required. + +--- + +### 5. Self-host skill forces a heavy end-to-end test instead of offering levels + +**Status: still present. Confirmed in `agenta-skills/skills/self-host-agenta/resources/test.md`.** + +The skill's `test.md` currently makes a full agent run mandatory: create an agent, run a prompt +and a tool call, write a session file, run again, and confirm the file persists. That is heavy +for a first check that the stack is up. + +**Fix (skill): make testing tiered, and let the user choose the depth.** +- **Default, always run: a quick sanity check.** Containers healthy, API returns 200, runner + health reachable, sign-up reaches the studio. This is fast and proves the stack is alive. +- **Then ask the user whether they want to go further,** and give the time cost so they can + decide: + - A short functional check: run one prompt through an agent. A few minutes. + - A full end-to-end check of all features: agent run, tool call, and object-store persistence + across runs. Roughly 15 to 20 minutes (we should measure the real number and put it in). +- Update `SKILL.md` (workflow step 4 and the resources note) so it describes the tiered flow + and the prompt, not a single mandatory end-to-end test. + +**Areas:** skill only (`agenta-skills` repo). + +--- + +### 6. Agents cannot reach GitHub without Composio, and nothing says so + +**Status: by design, not a bug. Confirmed.** Two findings land on the same reality: + +- `discover_tools` and `discover_triggers` return 404 on a bare OSS self-host. The endpoints + exist and are always mounted, but they route through the Composio gateway, which only + activates when `COMPOSIO_API_KEY` is set. With no key, the provider is missing and the call + 404s. `list_schedules` worked because it reads straight from the database and never touches + Composio (`api/entrypoints/routers.py:756-763`, `api/oss/src/core/tools/service.py:546`). +- Custom code tools (shelling out to `gh` or `curl`) are refused by the runner on purpose. The + executor was removed for security (`services/runner/src/engines/sandbox_agent/run-plan.ts:389-391`). + +Together this means a bare OSS deployment with no Composio key has neither gateway tools nor a +code escape hatch, so an agent has no way to reach an outside service like GitHub. That is a +legitimate setup, but nothing tells the self-hoster what to expect. + +**Fix (docs + skill).** Add a self-hosting section that explains, in plain terms, that gateway +tool and trigger discovery need Composio (`COMPOSIO_API_KEY`), that code tools are disabled by +design, and what works without Composio. Be specific that the triggers this affects are the +event-and-app triggers (a third-party app firing your agent), not the built-in schedule +triggers, which work without Composio. Improve the self-host skill the same way, so it sets the +same expectation during setup (see item 10 for the skill's setup prompt). + +**Areas:** docs + skill. + +--- + +### 7. Mounting a host folder for your agent + +**Status: docs already cover it. Add one small, end-of-setup pointer in the skill.** + +The full how-to already exists in the docs: +`docs/docs/self-host/agents/04-customize-the-agent-runtime.mdx` has an "Add folders" section with +the exact volume-mount recipe for the runner, the read-write versus read-only guidance, and the +Daytona alternative. No docs change is needed here. + +**Fix (skill only, kept minimal on purpose).** In the self-host skill, after setup is complete +and only when the user chose the local sandbox (not Daytona), add one short note: if you mount a +local folder into the runner, your agents can work on it, and point to the "Add folders" doc for +how. Keep it to a sentence or two. Do not add it during setup or for Daytona users, so we do not +overwhelm anyone. + +**Areas:** skill only. + +--- + +## Part 2: Already fixed in code; only image currency remains + +These two were real bugs when they were reported, but the fixes have since landed. For a +self-hoster they now reduce to running a runner image built after roughly 2026-07-18, which is +another reason publishing a current runner image (item 1) matters. + +### 8. Per-turn model injection cancelled pending approval prompts + +**Status: fixed.** The runner sets the model once through a structured call at session start +(`services/runner/src/engines/sandbox_agent/model.ts:108`), not by injecting a `/model` slash +command every turn. Warm and approval-resume paths never re-apply the model, so a pending +approval prompt is not cancelled. This was resolved by the approval-parking work (PR #5382). No +action needed for the injection itself. + +### 9. Thinking not working with fable and a Claude subscription + +**Status: very likely fixed. Needs one reproduction to confirm.** Extended-thinking display is +force-enabled for every Claude run with no gating by credential type +(`services/runner/src/engines/sandbox_agent/environment.ts:913-914`). The fix landed in commit +`afff099c4c` on 2026-07-18, which is after the report. Fable is a Claude model, so it runs +through the same path. + +**Action:** reproduce on a current runner image with harness `claude`, model `claude-fable-5`, +and a self-managed connection, using the `agent-release-gate` skill, and confirm thinking frames +appear on the stream. + +--- + +## Part 3: Separate bugs for other teams (not self-hosting) + +### 10. Missing-provider case returns a misleading 404, plus a skill setup prompt + +When Composio is not configured, `discover_tools` and `discover_triggers` return a bare 404, +which reads as "endpoint missing" rather than "provider not configured." A clearer 501 or 503 +with a "Composio not configured, set `COMPOSIO_API_KEY`" body would set expectations. + +**Actions:** +- File a GitHub issue for the misleading 404, owned by the API team. +- Improve the self-host skill so setup asks the user about Composio directly: gateway tools and + event-and-app triggers need a Composio account (a free tier exists), so the skill should ask + "do you want to set up Composio now for app tools and triggers, or continue without it?" Be + explicit that this is about event-and-app triggers, not the built-in schedule triggers, which + work without Composio. This pairs with the docs in item 6. + +--- + +## Decisions (resolved) + +1. **Cloud store region (item 2): resolved.** The cloud environment variables were checked and the + region is set correctly, so PR B is unblocked. + +2. **Kubernetes runner restart (item 3): resolved, patch it.** We make the single-replica chart set + a stable id, so a pod restart does not refuse local sessions. Multi-replica installs stay on the + random-per-pod path, which is correct for them. A Deployment pod gets a new name on restart, so + the fix is a fixed id gated on the replica count being 1, not a pod-name reference. + +3. **Claude login (item 4): resolved, proceed.** Share the real login read-write; non-1000 users + add the `user:` and `HOME` override. Confirm the one unproven step (harness CLI download as a + foreign user) during the live QA pass. + +--- + +## Suggested PR grouping + +- **PR A (agenta_cloud, private): DONE, agenta_cloud #1658.** Added the runner to both matrices in + `43-release-to-ghcr.yml`. Item 1. +- **PR B (public, code + compose): DONE, [#5404](https://github.com/Agenta-AI/agenta/pull/5404).** + Empty-region fix (API + OSS/EE compose) and stable replica id (compose + single-replica Helm). + Items 2 and 3. +- **PR C (public, docs): DONE, [#5406](https://github.com/Agenta-AI/agenta/pull/5406).** + Quick-start troubleshooting for the image 404, FAQ version-pin list, + the Composio-expectations section, the Claude login rewrite (share the real login, add the + `user:`/`HOME` override for non-1000 users, drop the copy method) and the claim fix, the + replica-id reference correction and single-runner note, and the optional mount pointer. Items + 1, 3, 4, 6, 7. +- **PR D (agenta-skills): DONE, agenta-skills #21.** Tiered-testing rewrite of `test.md` and + `SKILL.md`, the Composio setup decision, the local-only folder-mount pointer, the uid-override + login fix, and the two new troubleshoot entries (image 404 and daily token death). Items 4, 5, 6, + 7, 10. +- **Follow-up:** reproduce item 9 in live QA on a current runner image. Item 10 issue filed: + [#5407](https://github.com/Agenta-AI/agenta/issues/5407). + +Do not merge anything into main. Each PR stops at green and ready-for-review. diff --git a/docs/design/agent-workflows/scratch/debug-frontend-approval-dispatch.md b/docs/design/agent-workflows/scratch/debug-frontend-approval-dispatch.md new file mode 100644 index 0000000000..eddfaacfa0 --- /dev/null +++ b/docs/design/agent-workflows/scratch/debug-frontend-approval-dispatch.md @@ -0,0 +1,222 @@ +# Frontend approvals: why card 2 never dispatches, and why reload seals it + +Scope: the two frontend defects left after the approvals fix train (PR #5382), plus the +cosmetic "bash failed" badge on the UNKNOWN sentinel. All three were reproduced live on the +EE dev stack (port 8280, agent "Local Claude", Pi harness, anthropic/claude-sonnet-4-5) in +session `00bae2fb-1467-4a70-ac87-1ed17dcf898b` on 2026-07-19, and cross-checked against the +records of the earlier QA session `2bdd4407-725e-4078-9f87-9534d8f66008`. The runner side is +healthy: card 1's approval dispatched a resume, the approved call parked with the +`APPROVED_EXECUTION_RESULT_UNKNOWN` sentinel, and card 2 re-parked cleanly. Both defects are +purely frontend. + +## Shared background: what the resume turn does to the message parts + +Two facts about AI SDK v6 (`ai@6.0.0-beta.150`) drive both defects' shape. + +First, when an auto-resume request streams in and the last message is an assistant message, +the SDK does not create a new assistant message. `createStreamingUIMessageState` +(`web/oss/node_modules/ai/dist/index.mjs`, the `process-ui-message-stream` module) reuses the +last assistant message as the streaming target, so the resume turn's chunks are applied onto +the SAME message that turn 1 built. + +Second, within that continued message, tool chunks route by `toolCallId` to the EXISTING part +(`updateToolPart` and `getToolInvocation` find the part by id and mutate it in place), while a +`start-step` chunk always pushes a fresh `{type: "step-start"}` part at the TAIL of the parts +array. So after a resume, a re-parked gate's tool part keeps its turn-1 position in the array, +and the resume turn's `step-start` marker sits behind it. + +Observed live, the single assistant message after turn 2 parked (before any card-2 click): + +``` +idx 0 step-start (turn 1) +idx 1 reasoning (done) +idx 2 text (done) +idx 3 tool-bash toolu_01GDA7...YXN94P output-error + errorText = "APPROVED_EXECUTION_RESULT_UNKNOWN: ..." + approval = {id: 95f9e06a-..., approved: true} <- card 1, answered +idx 4 tool-bash toolu_01HLa8...g1WnPq approval-requested + approval = {id: a66702e5-...} <- card 2, re-parked +idx 5 step-start (turn 2) +``` + +The re-parked gate (idx 4) sits BEFORE the turn-2 `step-start` (idx 5), even though its +approval request arrived AFTER it on the wire. Part position no longer reflects event order. + +## Defect A: approving card 2 never sends the resume + +### The broken chain + +1. The user clicks Approve on card 2. The ApprovalDock resolves the card by `approval.id` + (`web/oss/src/components/AgentChatSlice/components/ApprovalDock.tsx:41` collects + `approvalId` from the part) and calls `handleApprovalResponse` + (`web/oss/src/components/AgentChatSlice/AgentConversation.tsx:1037-1043`), which records + the live marker `{kind: "approval", id: "a66702e5-..."}` and calls + `addToolApprovalResponse`. +2. `addToolApprovalResponse` (ai/dist/index.mjs, `AbstractChat`) flips the matching part in + the LAST message to `approval-responded` and immediately evaluates + `sendAutomaticallyWhen`. Observed post-click state: idx 4 became + `state: "approval-responded", approval: {id: a66702e5-..., approved: true}`. So the SDK + DOES re-evaluate the predicate, and the marker id DOES match the part. Neither of those + hypotheses is the failure. +3. The predicate `agentShouldResumeAfterApproval` + (`web/packages/agenta-playground/src/state/execution/agentApprovalResume.ts`) finds the + answered part: `lastResolvedIdx = 4` via the marker-identity match at lines 156-160. +4. It then runs the ALREADY RESUMED guard at lines 181-184: + + ```ts + const resumedAlready = parts + .slice(lastResolvedIdx + 1) + .some((part) => part.type === "step-start") + if (resumedAlready) return false + ``` + + `parts.slice(5)` contains the turn-2 `step-start` at idx 5, so `resumedAlready` is true + and the predicate returns false. This exact evaluation was replayed in the page against + the live React state: `{lastResolvedIdx: 4, stepStartAfter: [{j: 5}], resumedAlready: true}`. +5. No dispatch happens. Ground truth from the network log: the last + `POST /services/agent/v0/invoke` (reqid 2707, 21:13:55Z) is card 1's resume (its SSE + stream re-emits `tool-approval-request {approvalId: a66702e5, toolCallId: toolu_01HLa8...}` + and card 1's UNKNOWN `tool-output-error`). After the card-2 click there was no `/invoke` + and no interactions POST for over 60 seconds, only the routine streams/spans/mounts + polling. The same end state (answered card 2 plus trailing `step-start`, no resume) was + also found preserved in the QA session `2bdd4407` still mounted in the browser. + +### Why the guard exists and why it is wrong here + +The guard was added (commit `74ed562d`) to stop the post-resolve loop: on a COLD replay the +runner re-issues the approved tool under a fresh id, the old `approval-responded` part lingers +forever in the same message, and a position-based "did a new step start after it" check was +used to stop the predicate from re-sending after every completion. That check assumes part +position reflects event order. On a continued (warm-resumed) message it does not: the +re-parked gate is the SAME part object, updated in place at its turn-1 index, so every gate +answered after the first resume is always "behind" a `step-start` and can never dispatch. +Card 1 worked only because at click time the sole `step-start` was at idx 0, before it. + +### Minimal fix + +In `agentApprovalResume.ts`, do not apply the `resumedAlready` guard on the live-marker path. +When `liveInteraction` is set, the caller has just answered exactly that approval id in this +mount, and the wiring already resets the marker to null the moment a dispatch fires +(`AgentConversation.tsx:595`), after which the predicate returns false at line 153. So a +marker-matched `approval-responded` part can dispatch at most once per click and cannot loop. +Concretely: compute and honor `resumedAlready` only in the marker-less branch (the queue and +orphan checks at lines 162-172), for example by wrapping lines 178-184 in +`if (!liveInteraction) { ... }`. The marker-less path keeps the guard, which is the path the +original loop bug lived on. + +## Defect B: after reload, card 2 rehydrates dead + +### The record sequence that triggers it + +The durable record log for the repro session (verified via +`POST /api/sessions/records/query` for `2bdd4407-...`, identical shape in `00bae2fb-...`) +holds, in replay order: + +Turn 1 (`a36c716a`): + +1. user `message` +2. `thought`, `message` +3. `tool_call` c1 = `toolu_01SMvgymTgKSZkk8bYj6ncLb` (README) +4. `tool_call` c2 = `toolu_01Rp1ChwCeMBasRKbqbSMYvG` (NOTES) +5. `interaction_request` i1 = `1833336f-...` for c1 (only gate 1 is requested; Pi asks serially) +6. `tool_result` c2, `output = "DEFERRED_NOT_EXECUTED: paused for another approval; ..."`, + `isError: true` (the park terminalizes the sibling) +7. `done` + +Turn 2 (`066a69bf`, the resume): + +8. user `message` (the re-sent prompt is persisted again) +9. `interaction_response` i1 `approved: true` for c1 +10. `interaction_request` i2 = `6302bd4c-...` for c2 (the re-park) +11. `tool_result` c1, `output = "APPROVED_EXECUTION_RESULT_UNKNOWN: ..."`, `isError: true` +12. `done` + +(The turn-2 re-emit of c1's `tool_call` upserts the turn-1 row in place, so it does not appear +twice.) + +### The branch that seals the card + +Hydration is `transcriptToMessages` +(`web/oss/src/components/AgentChatSlice/assets/transcriptToMessages.ts`), which keys tool +parts by `toolCallId` in a transcript-wide index. Walking the sequence: + +- Row 4 creates c2's part in `input-available` and indexes it. +- Row 6 hits the `tool_result` case (lines 136-148): `isError` is true, so the part becomes + `state: "output-error"` with the DEFERRED sentinel as `errorText`. The tool_result case + does not distinguish runner bookkeeping sentinels from real execution results. +- Row 10, the turn-2 `interaction_request` for c2, hits the guard at lines 177-181: + + ```ts + // Only park if still unsettled — a later `tool_result` overwrites this. + if (part.state === "input-available") { + part.state = "approval-requested" + part.approval = {id: str(payload.id)} + } + ``` + + c2's part is `output-error`, not `input-available`, so the re-park is silently dropped. + The part keeps turn 1's DEFERRED result and never gets an `approval` field. + +Observed hydrated state after reloading the wedged session with its local cache cleared: + +``` +message 2 (assistant): + i 2 tool-Bash ...YXN94P output-error errorText "APPROVED_EXECUTION_RESULT_UNKNOWN: ..." + approval {id: 95f9e06a-..., approved: true} + i 3 tool-bash ...g1WnPq output-error errorText "DEFERRED_NOT_EXECUTED: paused for ..." + (no approval field) +message 3 (user): the duplicated turn-2 user row +``` + +No part is `approval-requested`, so `getPendingApprovals` (ApprovalDock.tsx:32-44) returns +nothing, no dock renders, and the turn is permanently uncompletable. The same dead state also +overwrites a healthy local cache: the SWR revalidate-on-open effect +(`AgentConversation.tsx:961-982`) adopts the server transcript whenever it has MORE messages, +and the duplicated turn-2 user row guarantees it does. + +### Minimal fix + +In the `interaction_request` case, let a later approval request supersede a SENTINEL-ONLY +result while never downgrading a real one. Recognize sentinels by their prefixes +(`DEFERRED_NOT_EXECUTED` and `APPROVED_EXECUTION_RESULT_UNKNOWN`, defined in +`services/runner/src/tracing/otel.ts:64-69`; the web app cannot import the runner package, so +mirror them as exported constants next to the transcript adapter and reuse them in +ToolActivity, which today hard-codes the deferred prefix at line 38). Concretely, replace the +`input-available` check with: park when `part.state === "input-available"`, or when +`part.state === "output-error"` and `part.errorText` starts with a sentinel prefix; when +re-parking, clear `errorText`/`output` and set `state: "approval-requested"` plus the new +`approval.id`. A real result (`output-available`, `output-denied`, or a non-sentinel +`output-error`) still wins over a stale request row, which preserves the existing "a later +tool_result overwrites this" invariant for genuinely executed calls. + +## The badge: UNKNOWN sentinel renders as a red "failed" + +`ToolActivity.tsx` special-cases only the deferred sentinel: `isDeferredError` (lines 38-40) +matches the `DEFERRED_NOT_EXECUTED:` prefix and renders the neutral clock icon and "waiting +on another approval". The UNKNOWN sentinel takes the generic `output-error` path instead: + +- `rowSummary` line 88 returns "failed", +- `StatusIcon` lines 102-105 renders the red `Warning` icon, +- the mid-text gets `type="danger"` at line 210, +- and the collapsed group summary counts it in `failed` (lines 351-361), producing the red + "Bash failed" / "N failed" badge. + +Fix in one move: add an `isUnknownResultError` (prefix `APPROVED_EXECUTION_RESULT_UNKNOWN`) +beside `isDeferredError`, treat it as neutral in all four spots (icon, row summary such as +"approved, result unknown", non-danger text, excluded from the `failed` count), sourcing both +prefixes from the shared constants introduced by the defect-B fix. This applies to live +rendering too, not just replay: the live turn-2 stream delivers the same sentinel as a +`tool-output-error` chunk, and the transcript showed the red "bash failed" before any reload. + +## Repro notes + +- Prompt: "Append the line \"hello from QA\" to agent-files/README.md and to + agent-files/NOTES.md, as two separate Bash commands issued in parallel in the same turn." +- Card 1 approval id `95f9e06a-...` dispatched `/invoke` (reqid 2707); card 2 approval id + `a66702e5-...` clicked afterward produced zero network traffic beyond polling. +- The predicate evaluation was replayed in-page against the live `useChat` state via the + React fiber (component `AgentConversation`, the messages hook), matching the code path + exactly: marker found the part (`lastResolvedIdx = 4`), `step-start` at index 5 vetoed it. +- Defect B was forced deterministically by deleting the session's entry from the + `agenta:agent-chat:messages` localStorage map and reloading, which routes hydration through + `loadSessionMessages` and `transcriptToMessages`. diff --git a/docs/design/agent-workflows/scratch/debug-warm-resume-execution-failure.md b/docs/design/agent-workflows/scratch/debug-warm-resume-execution-failure.md new file mode 100644 index 0000000000..40a8eef065 --- /dev/null +++ b/docs/design/agent-workflows/scratch/debug-warm-resume-execution-failure.md @@ -0,0 +1,210 @@ +# Root cause: warm resume never executes the approved command (2026-07-19 evening) + +This report explains why, on the EE dev stack (`agenta-ee-dev-wp-b2-rendering-*`), a Pi +session with two parallel gated Bash calls approves cleanly but never executes the approved +command. The failure was live in sessions `183f6297`, `324f72f5`, and `94ed7f8a` +(19:12 to 19:27 UTC), and I reproduced it deterministically at the wire level (session +`bbc4bdf2`, 20:15 UTC). Every claim below is backed by a timestamped runner log line, a +file-and-line code citation, or a scripted reproduction run against the live stack. + +## Orientation: the moving parts + +- **The runner** is the Node sidecar under `services/runner/`. Per environment it spawns a + **local sandbox daemon**: the `sandbox-agent server --host 127.0.0.1 --port ` + binary from the `@sandbox-agent/cli-linux-x64` package (resolved in + `services/runner/src/engines/sandbox_agent/daemon.ts`). That daemon in turn runs + `pi-acp` (the bridge that speaks the Agent Client Protocol, ACP, over HTTP) and the `pi` + harness process. "ACP write" in the logs is the runner POSTing a JSON-RPC message to that + daemon's localhost port. +- **A gate** is a human-approval request. Our Pi extension + (`services/runner/src/extensions/agenta.ts:224`) registers a `tool_call` hook that calls + `ctx.ui.confirm` and waits; pi-acp surfaces the confirm as an ACP + `session/request_permission`, which the runner shows as an approval card. +- **Park and warm resume.** An unanswered gate ends the turn and parks the live harness + session in a keepalive pool. When the answer arrives, the runner checks the session back + out and answers the still-pending permission request in place + (`run-turn.ts:637`, `respondPermission`). That is a warm resume. +- **The bounded closure wait** is new since this afternoon (fix-train step 2, commit + `4c8c809984`, authored 14:52 UTC). When a resume turn ends paused, the runner now holds + the turn open until every approved call reports a terminal frame, bounded per call by the + per-tool-call run limit (`run-turn.ts:675-689`, calling `waitForToolCallClosure` at + `run-turn.ts:225-250`). The bound is `resolvedRunLimits.toolCallMs`, whose default is + `DEFAULT_TOOL_CALL_TIMEOUT_MS = 5 * 60_000` (five minutes, `run-limits.ts:40`). + +## The root cause in one paragraph + +Pi's engine does not execute a parallel tool call the moment its approval resolves. In +`pi-agent-core`'s `executeToolCallsParallel` (in the installed package, +`dist/agent-loop.js` around lines 332-365), the preparation loop is sequential: it awaits +`config.beforeToolCall` for call 1 (that await is our confirm dialog, `agent-loop.js:405`), +pushes call 1's execution as a deferred thunk, then awaits `beforeToolCall` for call 2, and +only after the loop finishes does `Promise.all` run the execution thunks. So the approved +first command cannot start until the second command's confirm is also answered. The new +bounded closure wait assumes the opposite: it holds the resume turn open until the approved +call closes, which means the turn does not park, the second approval card does not reach a +settled turn the user can answer, and nothing can ever satisfy the wait. The two sides +deadlock, bounded only by the five-minute per-call limit. During those five minutes the +user sees "bash running", the Python agent service's stream watchdog eventually kills the +client stream with a generic "agent run failed", and no file is ever written. + +## The verified timeline (session 183f6297, all times UTC) + +1. **19:12:39** turn 1 starts cold; the environment acquires a fresh daemon on + `127.0.0.1:41765`. Pi raises the confirm for the first Bash call (`toolu_012Z3...`, + append to `agent-files/README.md`). At **19:12:48.562** the runner parks + (`state=awaiting_approval ttl=300000ms`) and the turn ends normally. +2. **19:13:16.046** the user's approval arrives: + `[keepalive] resume ... gates=1 answered=1 carried=0 approve=1`. One millisecond later + the runner answers the parked gate (`resume answered gate reply=once`), and at + **19:13:16.050** Pi raises the confirm for the second Bash call (`toolu_01JWz...`). + This is Pi's preparation loop advancing: answer for call 1 received, hook for call 2 now + awaited. Per the engine code above, call 1's execution has NOT started and will not + start until this second confirm resolves. +3. **19:13:16 to 19:18:16** nothing happens. The turn's heartbeats report `running=true` + every 30 seconds. The geesefs mount statistics show `0.00 write/s` for the entire + window, which proves the append never executed (the sandbox working directory is the + mount). No frame of any kind arrives for the approved call, so the bounded closure wait + cannot finish early. +4. **19:18:16.051**, exactly 300.001 seconds after the resume, the wait expires. The + runner settles the approved call with the `APPROVED_EXECUTION_RESULT_UNKNOWN` sentinel + (`tracing/otel.ts:68`), logs `prompt stopReason=paused`, re-parks with the second gate + pending (`(re-park) poolSize=3`, the observed pool growth), and persists + `tool_result` and `done`. +5. **19:21:26.760** the runner process receives SIGTERM: the dev container's `tsx watch` + restarted the server because source files changed (another agent was committing to the + working tree; the rebuild log `[build-extension] wrote dist/extensions/agenta.js` + follows at 19:21:32). The shutdown runs `[keepalive] destroyAll count=2`, killing the + parked daemons. The queued ACP writes to those daemons then fail: first + `ECONNRESET`/`other side closed` on in-flight sockets, then + `connect ECONNREFUSED 127.0.0.1:41765` and `127.0.0.1:33601` on retry, ending in + `ACP write error` and `[sandbox-agent] unhandledRejection`. Port 41765 is session + 183f6297's daemon and port 33601 is session 324f72f5's daemon; both had just been + deliberately killed. After this point the parked sessions are gone, so the user's + second approval can only take the cold path against a fresh sandbox, and the two + commands never run at all. + +Sessions `324f72f5` (resume 19:18:23, hang) and `94ed7f8a` (resume 19:22:16.035, second +gate at 19:22:16.039, re-park at 19:27:16.040, again exactly plus 300 seconds) show the +identical pattern. + +### The control that proves the regression + +The same box ran the same shape correctly this morning on pre-train code. In session +`db58551b` (see `debug-concurrent-approvals-db58551b.md`), the resume at 09:53:22.458 +answered gate 1, Pi raised gate 2 at 09:53:22.460, and the runner logged +`prompt stopReason=paused` and re-parked at **09:53:22.461**: three milliseconds, not five +minutes, because the pre-train code parked immediately instead of waiting for the approved +call to close. Both commands then executed once the last gate was answered, which is +exactly the engine contract described above. The morning log lines also carry the old +resume format (`gates=1 approve=1`, no `answered=`/`carried=` counters), confirming the +code difference. The overnight wire-level chain runs at 00:01 and 00:06 UTC +(`debug/qa-concurrent-approvals/chain_bash.json`, `chain_pi.json`) passed on that same +pre-train code and show the approved Pi call executing only on the turn after the LAST +gate was answered, which independently confirms the engine's batching behavior. + +## Reproduction (question 4) + +Both runs were made against the live stack at 20:12 to 20:20 UTC with the existing +wire-level driver (`debug/qa-concurrent-approvals/multi_gate.py`, Pi cell C3, local +sandbox). + +- **Single gate, approve: PASSES.** Session `65f26933`: warm resume answered the gate at + 20:13:05.494 and the prompt completed at 20:13:07.847 (`stopReason=end_turn`), with the + command executed and its output streamed (`tool-output-available`, reply "WROTE", + 2.4 seconds). With no sibling confirm pending, Pi's preparation loop finishes and the + batch executes immediately, so the closure wait is satisfied and never visibly delays + anything. +- **Two parallel gated calls: FAILS exactly like the live sessions.** Session `bbc4bdf2`: + turn 1 parked on gate A; the resume answered gate A at 20:15:02.090; Pi raised gate B at + 20:15:02.096; the turn then hung with `running=true` heartbeats; the product endpoint's + SSE stream errored with `"agent run failed"` (the agent service watchdog, at roughly + three minutes); the runner re-parked at **20:20:02.100**, 300.010 seconds after the + resume; neither `/tmp/qa-a-*.txt` nor `/tmp/qa-b-*.txt` was ever created. This repro + used the OpenAI model (`call_...` tool ids), while the live failures used Anthropic + (`toolu_...` ids), so the failure is the two-call shape, not the model. + +The single-gate pass plus the two-call fail narrows the defect to the train's park path, +exactly as the question anticipated. + +## Answers to the five questions + +**1. What is the process on 127.0.0.1:port, and why ECONNREFUSED?** It is the per-environment +local sandbox daemon: `sandbox-agent server --host 127.0.0.1 --port --token ...` +(binary from `@sandbox-agent/cli-linux-x64`, spawned by the runner's local provider; the +process tree inside the container is sandbox-agent, then pi-acp, then pi). The resume is +NOT targeting a stale port: every acquire and resume in the logs used the daemon's current +live port. The ECONNREFUSED writes are teardown noise with two triggers, both of which +kill a daemon while an ACP cancel or notification for it is still queued: (a) keepalive +eviction on `approval-ttl-expire` (a background loop on this box parks a "gh pr list" +approval every five minutes that nobody answers, so this fires regularly), and (b) the +19:21:26 SIGTERM `destroyAll` when the dev watcher restarted the runner because the +working tree changed. The `unhandledRejection` is the unawaited retry chain surfacing. +No daemon crashed on its own and there are no orphan daemons. The engine-quality +roadmap's daemon weakness (the `daemon.ts` package-root derivation) is about binary +resolution during refactors and is not implicated. + +**2. Is this a regression from the fix train?** Yes: step 2, commit `4c8c809984` +("only real executor evidence settles a paused turn's tool calls"), authored 14:52 UTC, +between the morning success and the evening failures. It added the bounded closure wait at +`run-turn.ts:675-689`. The released main's client-tools and deny-frame changes are not +implicated: the daemon-side packages (`pi-acp`, `sandbox-agent`, the Pi extension) have no +newer commits than the morning code, and the single-gate warm resume works on the current +tree. Step 4's carried-forward re-park is a bystander here: every failing resume logged +`carried=0`, and the second gate was a fresh gate, not a carried one. The pause +controller's park-mode destroy skip (`run-turn.ts:196`) behaved correctly; the sessions +stayed alive until the watcher restart killed them. + +**3. Why does fusermount fail, and is geesefs implicated?** Red herring. The +`fusermount -uz` failure at mount time is the pre-clean step of `mountStorage` running +against a path that is not currently mounted; the code then confirms the path is gone +("unmounted (confirmed gone)") and mounts fresh ("verified alive"). The same benign +pattern appears on every acquire, including this morning's healthy sessions. During the +hang the geesefs mounts were alive and their statistics logs kept printing; the +`0.00 write/s` readings are evidence that the command never ran, not that the mount was +broken. + +**4. Minimal reproduction.** Done, see above: single-gate warm resume executes (PASS); +the two-call shape hangs for exactly the five-minute per-call limit and executes nothing +(FAIL). Attribution therefore lands on the train's park path, not on the environment. + +**5. The defective code path and the minimal fix.** The defect is the unconditional +bounded closure wait at `services/runner/src/engines/sandbox_agent/run-turn.ts:675-689` +interacting with Pi's batched parallel execution +(`pi-agent-core/dist/agent-loop.js`, `executeToolCallsParallel`: sequential +`beforeToolCall` awaits, execution only after all confirms resolve). The wait's premise, +"an approved call is executing and will close on its own", is false on Pi whenever the +same resume turn surfaced a NEW unanswered gate. The minimal correction: skip the bounded +wait and settle the approved calls with `APPROVED_EXECUTION_RESULT_UNKNOWN` immediately +when the turn is a Pi turn and a fresh parkable gate is pending +(`plan.isPi && opts.approvalParkMode && env.parkedApprovals.size > 0` at that point in +`runTurn`), so the turn parks in milliseconds and the new card reaches the user; the +approved command then executes when the final sibling gate is answered, which is Pi's +engine contract and is what the morning and overnight runs did. Keep the wait for Claude: +the overnight `chain_bash.json` turn 2 shows Claude delivering the approved call's real +result inside the resume turn, which is precisely the case the wait was built for. A +follow-up worth considering upstream is asking Pi for per-call execution as approvals +resolve, but that is an engine change, not this fix. + +## Attribution + +The execution failure and five-minute hang are the fix train (step 2's bounded wait), not +the released main and not the infrastructure. Two environmental effects compounded the +user experience on this box tonight but did not cause the failure: the dev watcher +restarting the runner mid-incident destroyed the parked sessions (producing the +ECONNREFUSED and unhandledRejection noise and making the second approvals land cold), and +the agent service's stream watchdog turned the silent five-minute wait into a generic +"agent run failed" on the wire. The reported "bash not handled by this client" message was +not found in the runner source, the daemon packages, or tonight's runner logs; it is +consistent with the post-restart cold path and needs a session record to pin down, but +nothing in this incident's root cause depends on it. + +## Follow-ups + +- Fix the wait as above and add the missing regression shape to the runner tests: two + parallel Pi gates, approve the first, assert the resume turn parks within a bounded + short interval and that both commands execute after the second approval. +- The five-minute silence also shows a monitoring gap: a resume turn that emits no frame + for minutes while holding a checked-out session should log the wait it is in. +- The dev stack's watcher restart during a live incident destroyed the evidence sessions. + When debugging parked-session behavior on this box, freeze commits to the tree or run + the runner outside watch mode. diff --git a/docs/design/agent-workflows/scratch/desloppify-round2-assessment.md b/docs/design/agent-workflows/scratch/desloppify-round2-assessment.md new file mode 100644 index 0000000000..cfb757e73b --- /dev/null +++ b/docs/design/agent-workflows/scratch/desloppify-round2-assessment.md @@ -0,0 +1,318 @@ +# Desloppify round two: what about the two layers we did not touch? + +**Date:** 2026-07-18 +**Author:** staff-level code assessment (read-only) +**Method:** the desloppify skill set on the local branch `chore/desloppify-code-skill` +(commit `42db761f7e`). I applied its scoring dimensions — cohesion, naming, dead code, +duplicated logic, error handling, type safety, test coverage, and cross-module +architecture — rather than inventing my own. + +## Why this assessment exists + +We just finished a desloppify round on one piece of code: the "sandbox agent engine" +inside the runner. That engine used to be a single 1,500-line file that did everything. +We broke it into about ten small files, each with one job, and left behind a 44-line +front-door file that only points at them. + +A note on one word you will see throughout. In this codebase a *facade* is a small file +whose entire job is to re-export names from other files, so that everything outside has a +single, stable place to import from and never has to know how the work is split up +internally. `services/runner/src/engines/sandbox_agent.ts` is that front door; the real +work lives in the sibling folder `sandbox_agent/`. + +Mahmoud's question is about the two layers we did **not** clean up: + +- **Layer one — the rest of the TypeScript runner.** Everything under + `services/runner/src/` that is not the sandbox-agent engine: the web server, the tool + relay, session bookkeeping, telemetry, configuration, and the command-line entrypoint. + This is a Node service, meaning a long-running program written in TypeScript (a typed + dialect of JavaScript) that answers HTTP requests. +- **Layer two — the Python agent service.** The small Python program that exposes the + `/invoke` endpoint the playground calls, and that in turn talks to the runner. + +For each layer the decision Mahmoud actually needs is one of three: (a) it needs a full +desloppify round like the engine got; (b) it needs a handful of targeted fixes that I +name file by file; or (c) it is fine as it is. + +--- + +## The short answer + +| Layer | Verdict | One-line reason | +|---|---|---| +| **1. TypeScript runner** | **(b) targeted fixes — a sizeable list, but not a full round** | The code is disciplined and very well tested, but two files are genuine "does-too-many-things" modules and there are three real duplication themes. | +| **2. Python agent service** | **(c) fine as it is** | It is 462 lines of thin, well-documented wiring over the SDK, with 2,000+ lines of tests. Nothing to clean up beyond deleting a few back-compat shims later. | + +The rest of this document is the evidence. + +--- + +# Layer one: the TypeScript runner + +## What this layer is and what state it is in + +The runner is the program that actually drives a coding agent inside a sandbox. When a run +comes in, it acquires a sandbox, wires up the tools the agent may call, streams the agent's +output back, records telemetry, and keeps the session alive between turns. Excluding the +sandbox-agent engine we already cleaned, this layer is about 20 files and roughly 9,000 +lines of TypeScript. + +The first thing to say is that this is **unusually disciplined code**, and the desloppify +lens confirms it rather than the reverse. Across the whole layer there are essentially no +`TODO`/`FIXME` markers, no `@ts-ignore` (a comment that switches off type checking for the +next line), no swallowed errors hiding real failures, and no hardcoded external URLs beyond +documented in-network defaults. Error handling follows one clear house style: catch the +error, log the full detail to the server's own log, put only a short safe message on the +wire, and fail closed (deny rather than allow) when a security decision cannot be made. +Constant-time password comparison, request-body size caps, and protection against +malicious regular expressions are all present. This is not sloppy code. + +The test coverage is genuinely strong. Of the roughly 20 source files, only two tiny +helpers (a 47-line ID generator and an 18-line pair of environment-variable names) have no +dedicated test, and both are exercised indirectly. There are about 75 test files totalling +nearly 30,000 lines. So any cleanup here is a **low-risk, test-backed refactor**, not a +rewrite in the dark. + +So why is the verdict "targeted fixes" and not "fine as it is"? Two reasons: two files +have grown into "god-modules," and three kinds of duplication have spread across the layer. + +A *god-module*, in the desloppify vocabulary, is a file that trips two independent alarms +at once: it is large **and** it does several unrelated jobs. Size alone does not count — +a long file that does one thing well is fine. The two files below trip both alarms. + +## The single biggest cleanup target: the telemetry file + +`services/runner/src/tracing/otel.ts` is 1,601 lines and is the largest file in the entire +runner. Its job is telemetry: turning an agent's event stream into a tree of timed spans +and shipping them to Agenta's observability backend. (OpenTelemetry, abbreviated OTel, is +the industry-standard library for this; OTLP is its wire format.) + +This is a textbook god-module. Inside that one file live at least five genuinely +independent jobs: + +1. The shared export machinery: the registries, the exporter cache, the batch processor, + and the flush logic. +2. The attribute mapping: pure functions that translate a chat message into span + attributes. +3. One complete tracer for the in-process Pi harness. +4. A second complete tracer, about 500 lines on its own, driven by the sandbox agent's + events. +5. **Startup-banner text stripping** — three functions that strip the harness's startup + banner out of its text output. This has nothing whatsoever to do with telemetry, and + it already has its own separate test file. It is simply sitting in the wrong file. + +The natural split is five files: `trace-export.ts`, `span-attributes.ts`, +`pi-extension-tracer.ts`, `sandbox-agent-tracer.ts`, and `startup-banner.ts`. Every seam is +clean and the existing tests already cover each piece, so this is mechanical work. + +Two smaller notes on the same file. It uses TypeScript's `any` type — the escape hatch that +turns off all type checking for a value — in 30 places, because the incoming harness events +are loosely typed. Most of that could be recovered with a few small event interfaces. And +there is one dead field, `RunConfig.baggage`, that is written in one place and never read +anywhere; it should be removed or wired up. + +**Verdict for this file specifically: it warrants the same treatment the engine got.** It +is the one file in the runner that, on its own, would benefit from a focused desloppify pass. + +## The second god-module: the web server + +`services/runner/src/server.ts` is 1,271 lines. Its header comment says it is the HTTP +transport — the code that accepts requests and returns responses. But it is actually two +things bolted together. About 600 of its lines are the *keepalive dispatch engine*: the +subsystem that keeps a session's sandbox warm between turns, parks a run that is waiting for +an approval, and resumes it later. The strongest evidence that this is a separate subsystem +is that two of the test files already import it and test it on its own, as if it were a +standalone module — the tests treat it as separate even though the code does not. + +The fix is one clean extraction: move the keepalive subsystem into its own file (for +example `sessions/keepalive-dispatch.ts`) and re-point those tests at it. That leaves +`server.ts` as roughly 400 lines of honest HTTP plumbing, which is what its own comment +claims it is. There is also one 440-line function inside it whose two branches — resuming an +idle session and resuming after an approval — are near-duplicates that a shared helper would +collapse. + +## The one god-module in the tool subsystem + +The `tools/` folder (the tool relay and MCP bridge — the machinery that lets the agent +inside the sandbox call tools out in the world) is, on the whole, well factored. It has real +shared cores — a wire-protocol file, a schema-accessor file, a callback transport — and the +file headers narrate the history of every extraction. The family is healthy. + +The exception is `tools/relay.ts` at 748 lines, which carries four unrelated jobs: the +filesystem adapters that read and write relay files, the tool-execution dispatch, the poll +loop itself, and a set of argument-redaction helpers. Splitting the filesystem adapters into +`relay-host.ts` and moving the redaction helpers out leaves `relay.ts` as just the loop. + +## Three duplication themes that run across the whole layer + +Beyond the individual files, three kinds of copy-paste have spread across the runner. None +is dangerous today; all will multiply as the runner grows. + +1. **Every file writes its own logger.** Eight files in this layer each define their own + little function that writes a prefixed line to standard error, and 29 separate places + repeat the exact idiom `err instanceof Error ? err.message : err` to extract an error + message. There is no shared logging or error-formatting module. One `makeLogger(prefix)` + helper and one `errText(err)` helper would delete dozens of copies. + +2. **The tool-call protocol handler is written twice.** The runner speaks MCP (the protocol + a harness uses to discover and call tools) over two transports: an HTTP server for the + local Claude harness, and a standard-input/output shim uploaded into the sandbox. The two + files build the identical request-and-response envelopes; they differ only at the single + step where one runs the tool in-process and the other writes a relay file. The stdio file + even says in its header "Mirrors the HTTP one." The envelope building is safe to share. + +3. **The tool-kind dispatch is written twice.** `tools/dispatch.ts` claims in its header to + own tool dispatch "once," but `tools/relay.ts` re-implements the same branch and has + already drifted — the relay copy handles a direct-call case that the shared copy does not. + These should be unified so the direct-call branch is not stranded. + +## One structural oddity worth naming: sessions live in two places + +Session logic is split across two directories. `src/sessions/` holds the durable +persistence, the liveness heartbeat, and the interaction lifecycle. But the session *pool*, +the session *identity*, and session *continuity* live inside +`src/engines/sandbox_agent/` — physically inside the engine folder we just cleaned. The web +server reaches past the engine's front door and imports the pool and identity files +directly. + +This is worth understanding precisely, because it is the answer to the "does anything reach +around the facade" question. The engine's front door (`sandbox_agent.ts`) deliberately +re-exports only the run-driving surface. The session pool and identity files are not part of +that surface, so the server importing them directly is not a violation of the facade — it is +a sign that **those files are filed in the wrong folder**. They are runner-wide session +infrastructure that happens to sit under the engine directory. Moving `session-pool.ts` and +`session-identity.ts` up into `src/sessions/` would put all session code in one place and +remove the reach-around entirely. + +## Can this layer absorb the growth that is coming? + +The runner is about to grow more sandbox backends (E2B, Docker) and more harnesses (Codex, +OpenCode), and may run several instances in parallel. The desloppify lens asks whether the +current shape helps or fights that. + +**On new sandbox backends, the structure is genuinely good.** There is a clean interface, +`RelayHost`, and adding a backend means implementing it and registering a factory. The two +existing implementations prove the pattern. The one friction point is that the sandbox +handle is typed as `any`, so the compiler will not tell a Docker or E2B author which methods +they must provide. Typing that contract now would make the third backend cheap. + +**On new harnesses, it mostly absorbs, but one seam fights.** Harness delivery already forks +cleanly between the two harness classes. The friction is duplication theme two above: every +new tool-call *transport* copies the protocol envelope a third time, and each copy is a place +where a subtle bug could regress independently. Fixing that duplication before the next +harness lands is the highest-leverage move for growth. + +## Layer-one verdict, stated plainly + +**(b) A sizeable but bounded set of targeted fixes — not a full desloppify round.** The +engine needed a full round because it was one enormous file doing everything. This layer is +already mostly well-modularised, consistently styled, and heavily tested, so running the full +blind-review-and-triage cycle across all 20 files would find little. What it needs is a named +list: + +1. Split `tracing/otel.ts` into five files. *(This one file alone deserves the engine + treatment.)* +2. Extract the keepalive dispatch engine out of `server.ts` and re-point its tests. +3. Split the filesystem adapters and redaction helpers out of `tools/relay.ts`, and type the + `sandbox: any` contract. +4. Introduce a shared logger and error-formatter; delete the eight hand-rolled loggers and + 29 repeated error-slice lines. +5. Hoist the MCP protocol envelope into one shared file used by both transports. +6. Unify the tool-kind dispatch between `tools/relay.ts` and `tools/dispatch.ts`. +7. Move `session-pool.ts` and `session-identity.ts` out of the engine folder into + `src/sessions/`. +8. Small nits: `server.ts` should read the concurrency limit from the typed config instead + of re-parsing the environment variable; remove two dead `void err;` statements; move two + misplaced import lines to the top of their files; route the last two direct + `process.env` reads (`permission-plan.ts`, `apiBase.ts`) through the typed config so the + "one place reads the environment" rule holds everywhere. +9. Close two test gaps: add a dedicated test for `tools/tool-mcp-http.ts` (it is a security + boundary — bearer auth on a loopback server — yet only covered indirectly), and a test for + `sessions/auth.ts`'s credential refresh (the one wholly uncovered function in the layer). + +### Effort and how to verify + +The whole list is roughly **15 to 21 agent-hours**, and it splits naturally into +independent pull requests (one per god-module, one for the de-duplication, one for the small +nits and test gaps). Because this touches the telemetry sink that feeds observability, the +keepalive engine that is the heart of session continuity, and the tool-execution path, unit +tests alone are not quite enough at the end: **the work warrants the agent release gate** +(the wire-level end-to-end QA harness) as a final check, not just the existing unit suites. +Each individual extraction, however, is verified by the unit tests that already exist. + +--- + +# Layer two: the Python agent service + +## What this layer is and what state it is in + +This is the Python program at `services/oss/src/agent/`. It exposes the `/invoke` endpoint +the playground calls, and it hands the real work to the runner. It is 462 lines across nine +files, and the striking thing is how little it does on its own: + +- `app.py` (168 lines) builds one composition object and delegates to the SDK's agent + handler. It used to re-implement the streaming, folding, and capability-gating logic; that + now lives in the SDK, and the service just assembles the pieces and forwards the call. +- `config.py` (119 lines) reads the on-file agent template (the AGENTS.md text, the model, + the tools) and a few environment variables. +- `schemas.py` (80 lines) holds the JSON schemas the agent advertises so the playground + knows to render a chat box. +- `secrets.py`, `tools/gateway.py`, `tools/secrets.py`, `tools/resolver.py` are all **thin + re-exports** of the SDK's platform package. Their entire body is "import this from the SDK + under the old name so existing imports keep working." + +In other words, almost all the real logic lives one layer down in the Python SDK +(`sdks/python`), which is a different codebase and outside the scope of this question. This +service is a composition-and-forwarding layer by design. + +## Quality against the desloppify dimensions and the API conventions + +Measured honestly, this layer is clean. There are no swallowed exceptions, no `type: ignore` +markers, no `TODO`/`FIXME`, and no oversized functions. Every module carries a thorough +docstring that explains not just what it does but why it is shaped that way. The tests are +generous: about 2,056 lines of tests for 462 lines of code, including a 749-line test of the +invoke handler. + +I also judged it against `api/AGENTS.md`, the backend's own convention document, since the +prompt asked for that. Two points are worth stating: + +- The convention says application configuration should be centralised and not read via + scattered `os.getenv` calls. This service reads exactly three environment variables, and + all three live in `config.py` behind named accessor functions (`runner_dir`, `runner_url`, + `config_dir`). That is the spirit of the rule, not a violation of it — the reads are + centralised in the config module and consumed as functions. +- The convention says a service should raise its own domain exceptions rather than leak + lower-level ones. `select_backend` does exactly this: it raises `LocalSandboxNotAllowedError` + when a deployment refuses the local sandbox, rather than letting a raw failure escape. + +## Can this layer absorb the growth that is coming? + +Yes, almost for free. More sandbox backends and more harnesses barely touch this file, +because it forwards the sandbox, harness, and tool fields to the SDK and the runner rather +than switching on them itself. Adding E2B or Docker means adding a name to the enabled-provider +set, which is configuration, not code here. This is the least-affected layer in the whole +system. + +## Layer-two verdict, stated plainly + +**(c) Fine as it is.** There is no cleanup this layer needs. The only forward-looking note is +that a handful of the thin re-export files (`secrets.py`, `tools/gateway.py`, +`tools/secrets.py`) exist only to keep one deprecated integration test importing under the old +names. When that test (`test_resolve_secrets_http.py`) is finally removed, those shim files can +be deleted too. That is optional tidying, roughly **one agent-hour**, verified by the existing +unit tests, and it needs no release gate. + +--- + +# The two decisions on one page + +**Layer one, the TypeScript runner: (b) targeted fixes, a sizeable list.** The code is +disciplined and well tested, so it does not need a full desloppify round — but it does need +a named set of extractions and de-duplications, led by splitting the 1,601-line telemetry +file and pulling the keepalive engine out of the web server. Roughly 15 to 21 agent-hours, +best done as several small pull requests, finished with a run of the agent release gate. + +**Layer two, the Python agent service: (c) fine as it is.** It is thin, well-documented, +and heavily tested wiring over the SDK, and it absorbs the coming growth without change. +The only optional work is deleting a few back-compat shim files once a deprecated test is +removed, about one agent-hour. diff --git a/docs/design/agent-workflows/scratch/pr-5400-drive-mounts-review-handoff.md b/docs/design/agent-workflows/scratch/pr-5400-drive-mounts-review-handoff.md new file mode 100644 index 0000000000..4d08460d35 --- /dev/null +++ b/docs/design/agent-workflows/scratch/pr-5400-drive-mounts-review-handoff.md @@ -0,0 +1,921 @@ +# Review brief: PR #5400 backend (mounts/files) — multi-perspective review + +## Your task + +Review the backend portion of `https://github.com/Agenta-AI/agenta/pull/5400` (branch +`fe-refactor/drive-surfaces`, base `main`) from four independent angles, listed below. +This document gives you the context and the groundwork already done so you don't have +to re-derive it, but it is a starting point, not a ceiling. Investigate further on your +own, pull the PR and the repo yourself if you have the means to, and raise anything you +find that isn't already listed here. The four lenses are deliberately meant to produce +different findings from the same code; don't collapse them into one generic pass. + +Produce a written report, one section per lens, each with: concrete findings anchored to +a file and, where possible, a function or line; how confident you are in each finding; +and a recommendation (merge as-is / merge with a follow-up ticket / needs a change before +merge). Where two lenses point at the same piece of code from different angles, say so +explicitly rather than repeating the finding twice without cross-reference. + +## Who wrote this and why the review matters + +The PR is authored by Agenta's frontend engineer (Arda Erzin), who wrote the full +change end to end, including a substantial amount of backend code. It landed while the +CTO, who normally reviews backend architecture and conventions, is traveling. The person +requesting this review wants confidence that the backend portion fits the codebase's +established backend patterns before it merges, precisely because the author's primary +strength and day-to-day focus is frontend, not backend architecture. + +## What the PR does and why it exists + +Agenta lets an agent run in a session with a working directory ("mount") that the +product surfaces to the user as a file browser ("drive"). Two bugs motivated this PR: + +1. **A UI freeze (tracked as #5367).** When an agent wrote a large file tree into its + working directory (the PR's test case: an 11k-file git clone), opening that session's + file browser froze the browser's main thread for 2.5+ seconds, and refroze on every + session update. Root cause: the file browser rendered one DOM node per file, for the + whole tree, on every render. +2. **Slow initial load, independent of the freeze.** The file browser fetched the + *entire* file tree before showing anything, so opening a multi-GB mount could block + for seconds before any UI appeared, regardless of the DOM-node problem. +3. **Duplicated, drifted implementations.** Two separate components implemented file + browsing (the build page's `DriveDrawer` and the chat page's `FilesWindow`), and they + had grown apart over time. + +The PR's fix has a frontend half (virtualized rendering via `@tanstack/react-virtual`, +so only visible rows mount in the DOM; a single unified `DriveExplorer` component +replacing both old drawers) and a backend half that changes what the API can be asked +for: instead of only "give me everything under this mount," it now supports "give me +just the top level" and "give me the next 100 files," so the frontend never has to pull +a whole tree to show something on screen. + +This brief is scoped to the **backend half**. The frontend touches +`web/oss/src/components/Drives/*`, `web/oss/src/components/AgentChatSlice/*`, and a few +`web/packages/agenta-entities` / `agenta-api-client` files; mention it only if a backend +finding has a direct frontend-contract consequence (e.g., a response shape frontend code +depends on). + +## The repo's documented backend conventions (your ground truth to check the PR against) + +This is quoted near-verbatim from `api/CLAUDE.md`, the standing convention document for +everything under `api/`. Treat it as the codebase's own stated intent, and check whether +the PR's backend code follows it, not as something to take on faith — if you think a +convention itself is questionable, say so too, that's in scope for the architecture +lens. + +**Layering (required dependency direction):** Router → Service → DAO Interface → DAO +Implementation → DB. Core services depend on interfaces (`*DAOInterface`), not concrete +DB implementations. Concrete dependencies are wired only in `api/entrypoints/*`. + +**Domain-level exceptions:** define domain exceptions in the core layer +(`core/{domain}/types.py` or `dtos.py`), never raise `HTTPException` from a service or +DAO. Catch domain exceptions at the API boundary (router or a decorator) and convert to +HTTP responses. Use a base exception per domain. + +**Typed DTO returns:** service methods return Pydantic `BaseModel` DTOs, not raw dicts, +tuples, or `Any`. `Optional[DTO]` for a possibly-missing entity, `List[DTO]` for +collections. + +**Endpoint design conventions:** +- `POST /query` for filtering/search with payload support. +- `POST /{id}/archive` and `POST /{id}/unarchive` for lifecycle transitions. +- Revisioned resources expose `/revisions/retrieve`, `/revisions/commit`, + `/revisions/log`. +- Response envelopes include `count` plus payload (`item`/`items`). +- Explicit `operation_id` on every route. +- **Use cursor pagination via `Windowing`, not page-number pagination** (this is the + one line most directly relevant to this PR — see the pagination question under lens 3 + below). +- Parse query params via `Depends(...)`, optionally parse a request body, and merge the + two into one query object in `utils.py`. + +**Router style:** register routes in the router class's `__init__` via +`self.router.add_api_route(...)`; use `@intercept_exceptions()` at the boundary; +keyword-only function parameters with `*`. + +**Access control:** RBAC is an OSS feature, always on in both editions. Handlers call +the access-check function unconditionally; there's no edition gate around the check +itself. The vocabulary and enforcement service live in `api/oss/src/core/access/`. + +Reference examples the convention doc points to: `api/oss/src/apis/fastapi/workflows/`, +`api/oss/src/core/workflows/`, `api/oss/src/dbs/postgres/workflows/`. + +## What the PR actually changed (backend) + +Files touched, with the PR's own added/deleted line counts: + +| File | +/- | +|---|---| +| `api/oss/src/apis/fastapi/mounts/models.py` | +31/-0 | +| `api/oss/src/apis/fastapi/mounts/router.py` | +89/-0 | +| `api/oss/src/apis/fastapi/mounts/utils.py` | +62/-1 | +| `api/oss/src/core/mounts/dtos.py` | +10/-0 | +| `api/oss/src/core/mounts/service.py` | +775/-11 | +| `api/oss/src/core/store/storage.py` | +57/-0 | +| `api/oss/tests/pytest/unit/test_mounts_file_ops.py` | +214/-0 (new file) | +| `api/pyproject.toml` | +2/-0 (two new dependencies, see below) | +| `api/uv.lock` | lockfile regen, no manual review needed | + +Two new runtime dependencies were added to `api/pyproject.toml`: +`pathspec>=0.12,<0.13` (gitignore-pattern matching) and `stream-zip>=0.0.83,<0.1` +(streaming zip generation). Neither library appeared in this codebase before this PR. + +### The shape of the change + +`MountsService.list_files` (the one existing method the whole thing hangs off) grows +from a single unconditional "list everything under this prefix" implementation into +**three selectable views**, chosen by which query parameters are set: + +- **Shallow / `depth=1`** (`service.py:869`, the branch guarded by + `if depth == 1 and order is None and limit is None:`) — one directory level only (via + the new `ObjectStore.list_objects_shallow`, `storage.py`), optionally with + `with_counts=true` to also report each subfolder's immediate child count via a bounded + concurrent fan-out. This is what backs the lazy tree: expanding a folder in the UI now + calls this instead of fetching the whole subtree. +- **Recency / flat** (`order` or `limit` set, same function, the branch starting + `if order is not None or limit is not None:`) — a sorted, optionally-capped listing + that also reports a `total` independent of any `limit`, so a small "latest 5 files" + widget can show an accurate count without fetching everything. +- **Browse** (no params) — close to the original behavior: full tree plus synthesized + folder entries, used only when the explorer is actually opened. + +A fourth, genuinely new method, `list_files_page` (`service.py:1143`), backs the new +`GET /mounts/{id}/files/page` endpoint and implements **true cursor pagination**: it +loops `ObjectStore.list_objects_page` (`storage.py`, new), keeps exactly `limit` real +files, and returns an opaque `next_cursor`. This is what the frontend's infinite-scroll +flat view calls. + +**Git-aware filtering** is new and opt-in via a `git_aware: bool = Query(default=False)` +parameter added to the existing plain-listing endpoint as well as the two new ones. When +off (the default, preserving the existing contract for any other caller), the endpoint +returns the raw object-store listing, `.git` internals and all. When on (only the +Agenta UI's own queries set it), the service: +- always drops the `.git` directory (`_is_git_plumbing`, `service.py:165`) and a set of + runner-owned internal paths (`_is_internal_mount_path`, `service.py:135` — the + `agents/` namespace and `.agenta-*` markers); +- reads `.gitignore` files from the object store and applies them via the new + `pathspec` dependency, checking not just the literal path but every ancestor + directory (`_path_gitignored`, `service.py:180`) — this exists specifically to catch + deeply-nested descendants like pnpm's + `node_modules/.pnpm//node_modules/...`, which a naive per-path match on a + top-level `node_modules` pattern would miss; +- for the tree-descent used by the recency/flat git-aware path, skips reading any + `.gitignore` that lives inside a directory already known to be ignored + (`_load_gitignore_specs` / `_read_gitignore_specs`, `service.py:720`/`753`), capped at + `_MAX_GITIGNORE_FILES = 100`; +- for the cursor-paginated endpoint specifically, when it walks into an ignored + directory it doesn't page through it — it jumps the `start_after` cursor to + `f"{mount_base}{ignored_dir}/" + "￿"` (`_first_ignored_ancestor_dir`, + `service.py:222`, used inside `list_files_page`), a sentinel chosen because it sorts + after every real key under that prefix in the store's lexicographic order. + +**A directory-collapsing heuristic for the "recent files" view** +(`_rollup_recent_entries`, `service.py:237`) collapses a directory into one folder row +when its files' timestamps are "cohesive" — packed together more tightly than the gap +to the nearest file activity outside that directory on at least one side — implemented +as a single-linkage test using `bisect_left`/`bisect_right` over the distinct sorted +mtimes, no fixed time-window constant. Resolved shallow-to-deep. Purpose: a `git clone` +that stamps thousands of files at once should read as one event in "recent files," not +flood the list with arbitrary leaf filenames; a directory that mixes an old file with +new ones should still surface the new file individually. + +**A streaming "download all" archive** (`iter_archive_members`, `service.py:1264`, and +`stream_mounts_archive` in `utils.py`) uses the new `stream-zip` dependency to stream a +zip member-by-member (never buffered whole in memory), with a bounded ordered prefetch +(`_ARCHIVE_READ_CONCURRENCY = 8` files read ahead at a time) and `ZIP_AUTO` picking +32-bit vs 64-bit zip encoding per file by actual size. A new endpoint, +`POST /files/archive`, lets one call zip several mounts together — the product's +"drive" is actually a fold of two separate mounts (the session's working directory and +a separate `agent-files` mount) placed under different path prefixes in one archive. +Bundled into this same piece of work is a genuine, already-fixed bug: the object +store's `LastModified` is epoch **milliseconds**, but `datetime.fromtimestamp` expects +**seconds** — passing milliseconds straight through overflowed to a year outside +`datetime`'s valid range and raised mid-stream, after the 200 response headers were +already sent, which is why the symptom was a silently 0-byte zip rather than a visible +error. Now divided by 1000. + +**A route-ordering fix in `router.py`:** the two new routes, +`POST /files/archive` and `GET /{mount_id}/files/page`, are registered *before* their +more general siblings, `POST /{mount_id}/archive` and `GET /{mount_id}/files` +respectively (see `router.py` around lines 79-109 and 250-260 in the merged diff). +FastAPI matches routes in registration order, so without this, a request for +`/files/archive` would be captured by `/{mount_id}/archive` with `mount_id` parsed +(incorrectly) as the literal string `"files"`. The PR's fix is a comment plus manual +ordering, not a structural guard. + +### Full backend diff, for direct reference + +`api/oss/src/apis/fastapi/mounts/models.py`, `router.py`, `utils.py`, +`api/oss/src/core/mounts/dtos.py`, `api/oss/src/core/store/storage.py`: + +```diff +diff --git a/api/oss/src/apis/fastapi/mounts/models.py b/api/oss/src/apis/fastapi/mounts/models.py +index 84af63abd6..46e2380430 100644 +--- a/api/oss/src/apis/fastapi/mounts/models.py ++++ b/api/oss/src/apis/fastapi/mounts/models.py +@@ -36,6 +36,22 @@ class AgentMountQueryRequest(BaseModel): + name: str = "default" + + ++class ArchiveMount(BaseModel): ++ """One mount to include in an archive. `path` scopes it to a folder within the mount ("" = the ++ whole mount); `prefix` places its files under `prefix/` in the zip (the folded drive layout).""" ++ ++ mount_id: str ++ prefix: str = "" ++ path: str = "" ++ ++ ++class MountArchiveRequest(BaseModel): ++ """Zip several mounts into ONE archive (the drive folds cwd + agent-files into one tree).""" ++ ++ mounts: List[ArchiveMount] = Field(default_factory=list) ++ filename: str = "files.zip" ++ ++ + # --------------------------------------------------------------------------- + # Response models + # --------------------------------------------------------------------------- +@@ -57,8 +73,23 @@ class MountsResponse(BaseModel): + + + class MountFileListResponse(BaseModel): ++ count: int = 0 ++ # Full file count matching the request before any limit — lets a limited "latest N" listing ++ # still report the true total (the UI badge). Equals `count` for an unlimited listing. ++ total: int = 0 ++ # `total` is a FLOOR (the count-only scan hit its cap) — the UI shows "N+". False when exact. ++ total_capped: bool = False ++ files: List[MountFile] = Field(default_factory=list) ++ ++ ++class MountFilePageResponse(BaseModel): ++ """One cursor PAGE of a mount's flat (recursive, path-sorted) file listing — the basis for the ++ Files drawer's infinite-scroll flat view. `next_cursor` is an opaque token for the following page; ++ None means the listing is exhausted.""" ++ + count: int = 0 + files: List[MountFile] = Field(default_factory=list) ++ next_cursor: Optional[str] = None + + + class MountFileContentResponse(BaseModel): +diff --git a/api/oss/src/apis/fastapi/mounts/router.py b/api/oss/src/apis/fastapi/mounts/router.py +index 76f01d1001..aa80cb384c 100644 +--- a/api/oss/src/apis/fastapi/mounts/router.py ++++ b/api/oss/src/apis/fastapi/mounts/router.py +@@ -27,12 +27,14 @@ from oss.src.core.mounts.types import ( + + from oss.src.apis.fastapi.mounts.models import ( + AgentMountQueryRequest, ++ MountArchiveRequest, + MountCreateRequest, + MountCredentialsResponse, + MountEditRequest, + MountFileContentResponse, + MountFileDeletedResponse, + MountFileListResponse, ++ MountFilePageResponse, + MountFileWrittenResponse, + MountFolderCreatedResponse, + MountQueryRequest, +@@ -43,6 +45,7 @@ from oss.src.apis.fastapi.mounts.utils import ( + download_mount_file, + merge_mount_query, + sign_mount_credentials, ++ stream_mounts_archive, + upload_mount_file, + ) + +@@ -189,6 +192,16 @@ class MountsRouter: + response_model_exclude_none=True, + status_code=status.HTTP_200_OK, + ) ++ # Registered BEFORE "/{mount_id}/archive" so `POST /files/archive` (download-all zip) isn't ++ # captured as archiving a mount literally named "files". ++ self.router.add_api_route( ++ "/files/archive", ++ self.archive_mount_files, ++ methods=["POST"], ++ operation_id="archive_mount_files", ++ response_model=None, ++ status_code=status.HTTP_200_OK, ++ ) + self.router.add_api_route( + "/{mount_id}/archive", + self.archive_mount, +@@ -236,6 +249,16 @@ class MountsRouter: + response_model=None, + status_code=status.HTTP_200_OK, + ) ++ # Registered before "/{mount_id}/files" so `/files/page` isn't swallowed by the browse route. ++ self.router.add_api_route( ++ "/{mount_id}/files/page", ++ self.get_mount_files_page, ++ methods=["GET"], ++ operation_id="get_mount_files_page", ++ response_model=MountFilePageResponse, ++ response_model_exclude_none=True, ++ status_code=status.HTTP_200_OK, ++ ) + self.router.add_api_route( + "/{mount_id}/files", + self.get_mount_files, +@@ -487,6 +510,12 @@ class MountsRouter: + *, + path: Optional[str] = Query(default=None), + read: Optional[str] = Query(default=None), ++ order: Optional[str] = Query(default=None), ++ limit: Optional[int] = Query(default=None, ge=0), ++ depth: Optional[int] = Query(default=None, ge=1), ++ with_counts: bool = Query(default=False), ++ git_aware: bool = Query(default=False), ++ include_gitignored: bool = Query(default=False), + ): + await self._check(request, Permission.VIEW_MOUNTS) + +@@ -505,12 +534,53 @@ class MountsRouter: + project_id=UUID(request.state.project_id), + mount_id=mount_id, + path=path, ++ order=order, ++ limit=limit, ++ depth=depth, ++ with_counts=with_counts, ++ git_aware=git_aware, ++ include_gitignored=include_gitignored, + ) + return MountFileListResponse( + count=len(listing.files), ++ total=listing.total, ++ total_capped=listing.total_capped, + files=listing.files, + ) + ++ @intercept_exceptions() ++ @handle_mount_exceptions() ++ async def get_mount_files_page( ++ self, ++ request: Request, ++ mount_id: UUID, ++ *, ++ path: Optional[str] = Query(default=None), ++ cursor: Optional[str] = Query(default=None), ++ limit: int = Query(default=100, ge=1, le=1000), ++ git_aware: bool = Query(default=False), ++ include_gitignored: bool = Query(default=False), ++ ) -> MountFilePageResponse: ++ """One cursor page of the flat (recursive, path-sorted) file listing under `path` — the Files ++ drawer's infinite-scroll flat view. Never enumerates the whole subtree, so it's fast on any ++ mount size; carry `next_cursor` back to fetch the next page.""" ++ await self._check(request, Permission.VIEW_MOUNTS) ++ ++ files, next_cursor = await self.mounts_service.list_files_page( ++ project_id=UUID(request.state.project_id), ++ mount_id=mount_id, ++ path=path, ++ cursor=cursor, ++ limit=limit, ++ git_aware=git_aware, ++ include_gitignored=include_gitignored, ++ ) ++ return MountFilePageResponse( ++ count=len(files), ++ files=files, ++ next_cursor=next_cursor, ++ ) ++ + @intercept_exceptions() + @handle_mount_exceptions() + async def write_mount_file( +@@ -589,6 +659,25 @@ class MountsRouter: + path=path, + ) + ++ @intercept_exceptions() ++ @handle_mount_exceptions() ++ async def archive_mount_files( ++ self, ++ request: Request, ++ *, ++ archive_request: MountArchiveRequest, ++ ): ++ await self._check(request, Permission.VIEW_MOUNTS) ++ ++ return await stream_mounts_archive( ++ mounts_service=self.mounts_service, ++ project_id=UUID(request.state.project_id), ++ mounts=[ ++ (UUID(m.mount_id), m.prefix, m.path) for m in archive_request.mounts ++ ], ++ filename=archive_request.filename, ++ ) ++ + @intercept_exceptions() + @handle_mount_exceptions() + async def delete_mount_file( +diff --git a/api/oss/src/apis/fastapi/mounts/utils.py b/api/oss/src/apis/fastapi/mounts/utils.py +index 29b09e1a4f..f644b8a6af 100644 +--- a/api/oss/src/apis/fastapi/mounts/utils.py ++++ b/api/oss/src/apis/fastapi/mounts/utils.py +@@ -1,13 +1,20 @@ ++from datetime import datetime, timezone + from mimetypes import guess_type + from posixpath import basename +-from typing import Optional ++from stat import S_IFREG ++from typing import List, Optional, Tuple + from uuid import UUID + + from fastapi import Response, UploadFile ++from fastapi.responses import StreamingResponse ++from stream_zip import ZIP_AUTO, async_stream_zip + + from oss.src.core.mounts.dtos import MountCredentials, MountFileWritten, MountQuery + from oss.src.core.mounts.service import MountsService + ++# Regular-file mode for archive members (owner rw, group/other r). ++_ARCHIVE_FILE_MODE = S_IFREG | 0o644 ++ + + async def upload_mount_file( + *, +@@ -62,6 +69,60 @@ async def download_mount_file( + ) + + ++async def stream_mounts_archive( ++ *, ++ mounts_service: MountsService, ++ project_id: UUID, ++ mounts: List[Tuple[UUID, str, str]], ++ filename: str = "files.zip", ++) -> StreamingResponse: ++ """STREAM a zip of EVERY file across the given mounts as a binary download ("download all"). ++ ++ The drive folds cwd + agent-files into one tree, so each ``(mount_id, prefix)`` is placed under ++ ``prefix/`` in the zip. The archive is streamed member-by-member (never buffered whole), and the ++ service prefetches file bodies with bounded concurrency. ``ZIP_AUTO`` picks zip32/zip64 per file ++ by size, so large drives and >4 GB archives are handled. ++ """ ++ ++ async def members(): ++ async for ( ++ zip_path, ++ _size, ++ mtime, ++ body, ++ ) in mounts_service.iter_archive_members( ++ project_id=project_id, ++ mounts=mounts, ++ ): ++ # `mtime` is the store's LastModified as epoch MILLISECONDS (see StoreObject.mtime); ++ # `datetime.fromtimestamp` wants SECONDS — passing ms overflows to a year out of range ++ # and RAISES mid-stream (after 200 headers are sent), truncating the zip to 0 bytes. ++ modified_at = ( ++ datetime.fromtimestamp(mtime / 1000, tz=timezone.utc) ++ if mtime ++ else datetime.now(tz=timezone.utc) ++ ) ++ ++ async def _data(_body=body): ++ yield _body ++ ++ # Size from the actual bytes (not the pre-read listing) so a file changed between list ++ # and read can't desync the zip entry; ZIP_AUTO then picks zip32/zip64 accordingly. ++ yield ( ++ zip_path, ++ modified_at, ++ _ARCHIVE_FILE_MODE, ++ ZIP_AUTO(len(body)), ++ _data(), ++ ) ++ ++ return StreamingResponse( ++ async_stream_zip(members()), ++ media_type="application/zip", ++ headers={"Content-Disposition": f'attachment; filename="{filename}"'}, ++ ) ++ ++ + async def sign_mount_credentials( + *, + mounts_service: MountsService, +diff --git a/api/oss/src/core/mounts/dtos.py b/api/oss/src/core/mounts/dtos.py +index c3d9309ab0..858e76c165 100644 +--- a/api/oss/src/core/mounts/dtos.py ++++ b/api/oss/src/core/mounts/dtos.py +@@ -63,10 +63,20 @@ class MountFile(BaseModel): + # Object-store LastModified as epoch milliseconds; None when the store omits it. Lets the UI + # order files by recency regardless of how they were created (bash, Write tool, upload). + mtime: Optional[int] = None ++ # Direct-child count for a folder entry — only set when the recency view rolls a whole ++ # freshly-written directory (e.g. a `git clone`) up into ONE folder row instead of flooding the ++ # "recent files" list with its leaves. None for real files. ++ item_count: Optional[int] = None + + + class MountFileList(BaseModel): + files: List[MountFile] = Field(default_factory=list) ++ # Total real files matching the request BEFORE any limit — so a limited "latest N" listing can ++ # still report the true file count (the UI badge) without shipping the whole tree. ++ total: int = 0 ++ # `total` is a FLOOR, not exact — the count-only scan hit its cap on a very large tree, so the UI ++ # shows "N+". False for an exhaustive count. ++ total_capped: bool = False + + + class MountFileContent(BaseModel): +diff --git a/api/oss/src/core/store/storage.py b/api/oss/src/core/store/storage.py +index 789f737ec2..8d7fa1804b 100644 +--- a/api/oss/src/core/store/storage.py ++++ b/api/oss/src/core/store/storage.py +@@ -335,6 +335,63 @@ class ObjectStore: + ) + return results + ++ async def list_objects_page( ++ self, ++ *, ++ bucket: str, ++ prefix: str, ++ start_after: Optional[str] = None, ++ max_keys: int = 500, ++ ) -> "Tuple[List[StoreObject], bool]": ++ """One PAGE of objects under `prefix`, in the store's lexicographic (= path) order, resuming ++ strictly AFTER `start_after`. Returns `(objects, has_more)`. Streams lazily and stops after ++ `max_keys` — so it NEVER enumerates a huge subtree; this is the basis for cursor pagination ++ (the caller carries the last key forward as `start_after`). One extra element is pulled to ++ detect `has_more`, then dropped.""" ++ client = self._client() ++ results: List[StoreObject] = [] ++ async for obj in client.list_objects( ++ bucket, prefix=prefix, recursive=True, start_after=start_after or None ++ ): ++ if obj is None: ++ continue ++ # We already have a full page — the presence of one more object means more remain. ++ if len(results) >= max_keys: ++ return results, True ++ last_modified = getattr(obj, "last_modified", None) ++ mtime = int(last_modified.timestamp() * 1000) if last_modified else None ++ results.append( ++ StoreObject(key=obj.object_name, size=obj.size or 0, mtime=mtime) ++ ) ++ return results, False ++ ++ async def list_objects_shallow( ++ self, ++ *, ++ bucket: str, ++ prefix: str, ++ ) -> "tuple[List[StoreObject], List[str]]": ++ """One directory LEVEL under `prefix` (delimiter `/`): the immediate file objects, plus the ++ immediate subdirectory prefixes (full keys ending in `/`). Lets a caller descend the tree and ++ prune whole subtrees (a gitignored `node_modules`) WITHOUT enumerating their contents — the ++ `recursive=True` flat listing has no way to exclude a prefix, so a mount full of dependency ++ files must otherwise be scanned in its entirety.""" ++ client = self._client() ++ files: List[StoreObject] = [] ++ subdirs: List[str] = [] ++ async for obj in client.list_objects(bucket, prefix=prefix, recursive=False): ++ if obj is None: ++ continue ++ name = obj.object_name ++ # A common-prefix (subdir) or an explicit empty-folder marker both end in `/`. ++ if getattr(obj, "is_dir", False) or name.endswith("/"): ++ subdirs.append(name) ++ continue ++ last_modified = getattr(obj, "last_modified", None) ++ mtime = int(last_modified.timestamp() * 1000) if last_modified else None ++ files.append(StoreObject(key=name, size=obj.size or 0, mtime=mtime)) ++ return files, subdirs ++ + async def get_object( + self, + *, +``` + +`api/oss/src/core/mounts/service.py` is the largest file in the diff (+775/-11) — too +long to reproduce in full here without bloating this brief past the point of being +readable. Pull it directly: `git diff origin/main...pr-5400-drive-surfaces -- +api/oss/src/core/mounts/service.py` against a checkout of the branch, or view the PR's +"Files changed" tab. The function-by-function description above, with line numbers +against the post-merge file, should let you navigate straight to the parts each lens +cares about instead of reading top to bottom. + +Note also `api/oss/src/core/store/storage.py`'s pre-existing `_client()` method (not +touched by this PR, but called by every new method added to `ObjectStore`, including the +two new ones above): it constructs a fresh `Minio` client object on every call, with no +caching or connection reuse across calls. This is an existing pattern in the codebase, +not something the PR introduced, but the PR does add several new call sites that hit it +(the shallow-listing methods in particular get called once per directory level during a +tree descent) — worth an opinion on whether that existing pattern is fine to keep +building on, or whether increased call volume makes it worth revisiting now. + +### What the PR's own description says about testing + +Quoted directly from the PR body: "Backend: unit tests for the paged listing in +`api/oss/tests/pytest/unit/test_mounts_file_ops.py`... Frontend: type-clean (`tsc`) and +lint-clean across the Drives module... The Fern client was not regenerated for +`/files/page`. The drive module calls it via axios, the same way it reaches its other +binary/download routes. Noted as a follow-up if we want it in the generated client." + +No mention of integration tests exercising the new endpoints end to end (through the +router, with a real or fake object store, asserting on the persisted access-control +behavior), and no mention of tests for the git-aware pruning behavior, the cursor +jump-past-ignored-directory logic, or the rollup heuristic specifically — only that unit +tests exist for "the paged listing." Confirm what the 214-line test file actually +covers versus what it doesn't; don't take the PR description's summary as complete. + +## The four review lenses + +### Lens 1 — Organizational fit: does the backend code follow the codebase's own conventions? + +The PR was written by an engineer whose day-to-day depth is frontend. The question here +isn't "is the code correct" (that's lens 4) but "would someone who works in this backend +every day recognize this as backend code written the way we write backend code here." +Check the new code in `service.py`, `router.py`, `models.py`, `dtos.py`, and `utils.py` +against every convention quoted in the "documented backend conventions" section above: +layering direction, domain exception usage (does any new code raise `HTTPException` +directly, or leak a store/library exception past the service boundary?), typed DTO +returns (note that `list_files_page` returns a raw `Tuple[List[MountFile], +Optional[str]]` rather than a named DTO — is that a deviation worth flagging, or +consistent with how the rest of this domain already does things?), and the endpoint +design conventions (see lens 2 for the pagination-specific version of this question). +Also assess: does the new code fit the *style* of the surrounding file (naming, +docstring conventions, comment density — the repo's own `api/CLAUDE.md` says to keep +AI-generated comments minimal and only explain non-obvious "why," which this PR's +comments mostly attempt to do; judge whether they succeed), or does it read as a +different hand with different habits dropped into the middle of an existing file? + +### Lens 2 — Endpoint organization, interface design, and testing practice + +The documented convention is `POST /query` for filtering/search with a payload, and +cursor pagination via a `Windowing` utility (referenced in `api/CLAUDE.md`, implemented +elsewhere in the codebase — find it and compare). This PR instead exposes filtering and +pagination entirely through `GET` query parameters (`order`, `limit`, `depth`, +`with_counts`, `git_aware`, `include_gitignored`, `cursor`) on `GET /{mount_id}/files` +and the new `GET /{mount_id}/files/page`, with a hand-rolled opaque cursor (a +mount-relative store key, not whatever `Windowing` produces elsewhere). Decide whether +that's a defensible choice, this is pagination over an object store's own key ordering, +not a database query, so the existing `Windowing` machinery (presumably built around DB +cursor semantics) may not fit, or whether it's a case of a second, parallel pagination +convention now existing in the codebase for no strong reason, which is exactly the kind +of drift the layering rules exist to prevent. Also look at whether `POST /files/archive` +taking a list of mounts in its body is consistent with how other "operate on several +things at once" endpoints are shaped elsewhere in the API, if any exist to compare +against. + +On testing: read the actual contents of `test_mounts_file_ops.py`, not just its line +count, and assess coverage against the surface area described above (three `list_files` +view branches, the new pagination method, the git-aware pruning and its directory-jump +behavior, the rollup heuristic, the archive streaming path) using whatever your view of +this codebase's normal test bar is. Recall from the PR body: the generated API client +(Fern) was **not** regenerated for the new `/files/page` endpoint, and the frontend +calls it via raw axios instead, matching how it already reaches other binary/download +routes. Decide whether that gap (a real endpoint with no generated client entry) is an +acceptable, precedented shortcut here or something that should block merge or get an +explicit follow-up ticket. + +### Lens 3 — Architecture: general solution or point patch? + +Several pieces of this PR are worth weighing as "well-designed general primitive" versus +"clever one-off built to solve exactly this bug." Form your own view on each, and add +any others you find: + +- **The three-views-in-one-method shape of `list_files`.** Is branching on which + optional query parameters are set the right way to express "shallow listing" vs + "recency view" vs "browse," or would three distinct, named service methods (as + `list_files_page` already is, separately) have been clearer and easier to test and + reason about independently? The current function is now several hundred lines with + three largely-independent code paths sharing one signature. +- **Where git-awareness belongs.** `git_aware` bakes gitignore-parsing and git's + directory-based ignore semantics directly into `MountsService`, a service whose other + job is generic object-store-backed file storage with no inherent git concept. Is + embedding git semantics at this layer the right call, or should "a mount that happens + to be a git working copy" be a distinct concept (a wrapping view, a decorator, a + separate service) layered on top of a git-agnostic mounts service? Consider both + directions: coupling now buys simplicity and one code path; separating it buys a + mounts service that stays conceptually clean but costs an extra layer. +- **The rollup heuristic's maintainability.** `_rollup_recent_entries`'s single-linkage + cohesion test (no fixed time-window constant, resolved shallow-to-deep) is a genuinely + interesting piece of algorithm design. Assess it on its own terms: is the algorithm + correct for the cases it's meant to solve, are there degenerate inputs where it does + something surprising (e.g., files with no mtime at all, a directory with exactly two + files, a mount where the *only* activity ever recorded is one big batch so there's no + "outside" gap to compare against), and, separately from correctness, will another + engineer six months from now be able to safely modify this without re-deriving the + reasoning from scratch? A comment explaining intent is not the same as an + implementation being easy to safely change. +- **Concurrency and cap constants.** `_LIST_CONCURRENCY = 24`, `_ARCHIVE_READ_CONCURRENCY + = 8`, `_COUNT_CAP = 20000`, `_MAX_GITIGNORE_FILES = 100` are all hardcoded module-level + constants with a one-line rationale each. Are these the right order of magnitude, are + they in the right place (should any be configurable per deployment, the way other + tunables in this codebase go through `api/oss/src/utils/env.py`, per the repo's + environment-config convention?), and do they compose safely, e.g. does a mount that is + both very deep (many directory levels, stressing `_LIST_CONCURRENCY`) and has many + `.gitignore` files (stressing `_MAX_GITIGNORE_FILES`) degrade gracefully or badly? +- **Whether this generalizes as mounts grow.** The level-by-level descent + (`_list_pruned_files`) trades "never enumerate an ignored subtree" for "wall-clock now + tracks tree depth, walked with bounded fan-out, rather than raw object count." Is that + the right tradeoff at the sizes this product expects mounts to reach, and does the + bounded fan-out (`_LIST_CONCURRENCY`) still hold up if a mount has a very wide + directory (thousands of siblings at one level) rather than a very deep one? +- **The pre-existing `_client()` pattern** (constructs a new store client per call, no + reuse) that this PR's new methods lean on more heavily than before, flagged above. + Judge whether this PR is the point where that existing pattern starts to matter. + +### Lens 4 — Correctness and the trust boundaries this code sits on + +This lens is about whether the new code behaves correctly under adversarial or +unexpected input, not just the happy path exercised by the PR's own QA checklist. Note: +frame every finding here in terms of correctness, access-control checks, tenant/project +scoping, input handling, resource use, and data exposure through responses, avoid +reaching for alarmist framing; this is a routine internal engineering review, not an +incident report. If your own research process wants to go further (reading the object +store's access-token-issuance code path, tracing how a project-scoped identity is +established elsewhere in this codebase, comparing against how other domains in this +API handle the same class of concern), do that on your own initiative; nothing here is +telling you not to look, only asking you to write findings up in plain engineering +terms. + +Concrete starting points, all in the diff above: + +- Both new endpoints (`archive_mount_files`, `get_mount_files_page`) call + `await self._check(request, Permission.VIEW_MOUNTS)`, the same check the existing + sibling endpoints use. Confirm that's the right permission for what these endpoints + actually do (note `archive_mount_files` only *reads*, matching `VIEW_MOUNTS`, but + produces a bulk export across potentially several mounts in one call — is a bulk-read + operation equivalent to a single-file read from a permission-modeling standpoint, or + should it be treated differently?). +- `MountArchiveRequest.mounts` is a caller-supplied list of `{mount_id, prefix, path}`. + Trace `archive_mount_files` → `stream_mounts_archive` → `iter_archive_members` + (`service.py:1264`) and confirm each `mount_id` in that list is independently resolved + against the *same* `project_id` taken from the request context (via + `_resolve_mount`), and that there's no path by which a caller could reference a mount + belonging to a different project by ID. Also confirm there's no upper bound on how + many mounts, or how much total data, one archive request can ask for. + `list_files_page` + is similar: `path` is user/agent-influenced (it's a directory that exists because an + agent or a user wrote files there) and flows into a store prefix and into + `.gitignore` object reads; confirm the existing `validate_file_path` (used elsewhere + in this file for the `path` parameter) is actually applied everywhere a path-shaped + input enters the new code, including inside `list_files_page` and the git-aware + branches of `list_files`. +- The gitignore-parsing path reads file *contents* from the object store and feeds them + into `pathspec.PathSpec.from_lines`, wrapped in a bare `except Exception` that + silently drops a bad file (`_load_gitignore_specs`, `_read_gitignore_specs`). Confirm + that's an appropriate failure mode (never breaking a listing because of one malformed + file) versus a place where a very large or pathologically-crafted `.gitignore` could + cost more than intended before the `except` catches it, the file is read into memory + in full via `get_object` before being handed to `pathspec`, with no size check + visible in this diff. +- Resource use / abuse resistance: `_COUNT_CAP` bounds the count-only path, and + `list_files_page`'s `limit` is bounded to `le=1000` at the router (`router.py`, + `get_mount_files_page`), but the plain `GET /{mount_id}/files?limit=...&order=...` + path takes `limit: Optional[int] = Query(default=None, ge=0)` with **no upper bound**. + Confirm whether that's intentional (this path returns a capped/count-only response in + some branches) or a gap relative to the new paginated endpoint's explicit `le=1000`. +- Data exposure through error responses: confirm the new endpoints' error paths (a + missing mount, an invalid cursor, a path that doesn't exist) go through the same + domain-exception-to-HTTP translation as the rest of this domain (per the + "domain-level exceptions" convention above) rather than leaking a raw store or + library exception message to the client. +- The mtime bug already fixed in this same PR (milliseconds passed where seconds were + expected, described above) is worth an independent look for siblings: are there other + places in this diff, or in the surrounding code the diff touches, doing arithmetic on + a timestamp or a size where a unit mismatch could produce a wrong-but-not-obviously- + broken result rather than a hard failure? + +## What's already been checked and is not an open question + +- No database migration is part of this PR. +- The backend and frontend were rebuilt and deployed to a local instance; both started + cleanly with no import errors from the two new dependencies, and a smoke pass through + the new file-browser behavior (opening a large mount, switching views, reading files) + worked as described in the PR. +- One single-file read was observed taking roughly 20 seconds on first access in that + local instance. Investigated and ruled out as a PR issue: the code path involved + (`read` query param → `MountsService.read_file` → `read_file_bytes` → a single + `get_object` call) has zero lines changed in this PR; the file itself is 13KB; and + three immediate repeat requests for the same file completed in 10-40 milliseconds + each. Most likely a one-time environment effect (the API process had just been + restarted to pick up the new dependencies) rather than anything reproducible. Treat + this as closed unless your own testing reproduces it differently. + +## Reference material + +- PR: `https://github.com/Agenta-AI/agenta/pull/5400` — "[AGE-3965] refactor(drive): + virtualize surfaces + unify Files drawer," branch `fe-refactor/drive-surfaces` into + `main`, 8 commits, +7279/-2938 across 51 files. +- Standing backend convention document (quoted from above, read in full if useful): + `api/CLAUDE.md` at the repository root. +- Domain folder structure to compare against, per that convention doc's own worked + examples: `api/oss/src/apis/fastapi/workflows/`, `api/oss/src/core/workflows/`, + `api/oss/src/dbs/postgres/workflows/`. + + + +## [Codex] Backend review report + +> **[Codex] Review target:** PR #5400 at `3ac6af25160a4b2546c682137f9d3ed5865fabe0`, +> compared with `origin/main` after a fresh fetch on 2026-07-20. This report covers the +> backend diff and direct frontend contract consequences only. + +### [Codex] Executive verdict + +**[Codex] Recommendation: needs changes before merge.** The route permission and project +scoping are correct, the object-store cursor is a reasonable reason not to reuse the +database-oriented `Windowing.next: UUID`, and the API, integration, acceptance, format, and +lint checks are green. Those positives do not cover several untested correctness and resource +problems in the new code. + +The minimum pre-merge set is: + +1. Make archive generation byte-streaming and validate every archive entry path. Do not hold up + to eight complete file bodies in memory. +2. Replace the U+FFFF ignored-directory cursor sentinel with a real prefix-successor operation, + and add a regression test with a filename that sorts after U+FFFF. +3. Make lazy-browse path validation accept ordinary filesystem names while still rejecting + absolute paths, traversal segments, NUL, and other genuinely unsafe input. +4. Type archive mount IDs as `UUID`, sanitize the response filename, and validate archive request + cardinality and path fields. The first two items already have CodeRabbit comments; this report + independently confirms them. +5. Add direct tests for `list_files_page`, archive streaming, the recent-directory rollup, nested + gitignore overrides, and route-level request validation. The current added test code does not + call `list_files_page`. +6. Either make `git_aware` behavior consistent with Git across views or document and name it as a + best-effort presentation filter. The current single flag has different semantics in shallow, + recency, browse, and page modes. + +### [Codex] Information sufficiency and assumptions + +**[Codex] No missing information blocks this review.** Two operational contracts are not stated: +the supported maximum single-file/archive size, and whether `git_aware=true` promises Git-compatible +ignore semantics. I assumed the archive must support multi-GB drives because the new docstrings say +that it does, and that a parameter named `git_aware` should behave consistently enough not to return +files Git would ignore or hang while skipping them. If either assumption is intentionally weaker, +the API and docs should say so explicitly. + +### [Codex] Lens 1: organizational fit with backend conventions + +| Codex finding | Evidence and assessment | Confidence | Recommendation | +|---|---|---|---| +| **[Codex L1-F1] The new paged service contract returns an unnamed tuple instead of a core DTO.** | `MountsService.list_files_page` returns `Tuple[List[MountFile], Optional[str]]` at `api/oss/src/core/mounts/service.py:1061`, and the router unpacks it at `api/oss/src/apis/fastapi/mounts/router.py:569`. This departs from the documented typed-DTO return convention. A `MountFilePage` core DTO would give the cursor contract one canonical home. | High | **Needs a change before merge.** | +| **[Codex L1-F2] Archive inputs lose their meaning at the core boundary.** | `ArchiveMount` is a Pydantic API model, but the router converts it into `List[Tuple[UUID, str, str]]`, which flows through `stream_mounts_archive` into `iter_archive_members` (`models.py:39`, `router.py:664`, `utils.py:72`, `service.py:1182`). The tuple slots represent source identity, source path, and archive destination mapping, which are different semantic roles. | High | **Needs a change before merge.** Use a named core DTO such as `MountArchiveSource(mount_id, source_path, archive_prefix)`. | +| **[Codex L1-F3] The code follows the router, permission, and project-scope conventions.** | Both new routes have explicit operation IDs, boundary decorators, keyword-only options, and unconditional `VIEW_MOUNTS` checks (`router.py:195`, `252`, `551`, `662`). `_resolve_mount` queries by both `project_id` and `mount_id` for every archive source (`service.py:583` and `1200`). No new service code raises `HTTPException`. | High | **Merge as-is for these aspects.** `VIEW_MOUNTS` is consistent with existing single-file reads. | +| **[Codex L1-F4] The PR deepens an existing concrete storage dependency and makes the service substantially less cohesive.** | `MountsService` already depends on concrete `ObjectStore`, so the PR did not create that debt. It adds shallow paging, Git filtering, temporal rollup, and ZIP orchestration directly to the same file, adding 775 lines. The narrative comment density also exceeds the API guide's preference for terse comments that explain only non-obvious invariants. | High on fit; medium on timing | **Merge with a follow-up ticket** for an object-store interface and service extraction, after the functional blockers are fixed. | +| **[Codex L1-F5] Predictable archive failures occur after the HTTP response can start.** | Mount resolution and object listing happen inside the `StreamingResponse` iterator (`utils.py:87` and `service.py:1198`). A missing second mount can therefore produce a truncated 200 download instead of a normal 404. New list adapter failures also become generic 500s instead of `MountStorageUnavailable`. | High | **Needs a change before merge.** Preflight mount IDs and request paths before returning the streaming response; translate adapter failures at the store boundary. | + +### [Codex] Lens 2: endpoint organization, interface design, and testing practice + +| Codex finding | Evidence and assessment | Confidence | Recommendation | +|---|---|---|---| +| **[Codex L2-F1] The GET verbs and native object-store cursor are defensible.** | These are read-only listing operations. Shared `Windowing` is database-shaped: its `next` token is a UUID (`sdks/python/agenta/sdk/models/shared.py:203`), while this endpoint must resume after an object key. Reusing it would misrepresent the storage contract. | High | **Merge as-is** on verb and cursor mechanism. Model the string cursor in a page DTO and document that it belongs to one query shape. | +| **[Codex L2-F2] One endpoint expresses several operations through optional fields and silently accepts invalid combinations.** | `get_mount_files` combines file reading with shallow, recency, count-only, and full-browse listing (`router.py:506`). Only exactly `depth == 1` selects shallow mode, `order` or `limit` selects flat mode, and everything else selects full browse (`service.py:845`, `951`, `1014`). Thus `depth=2` means unlimited recursive browse, `depth=1&limit=5` ignores depth, an unknown `order` returns an unsorted flat listing, and `with_counts` is ignored outside shallow mode. | High | **Needs a change before merge.** Use literal/enum fields and reject conflicting combinations with a typed query model. Prefer named listing methods or a discriminated `view`. | +| **[Codex L2-F3] The response field `total` changes meaning across modes.** | The response documents it as full file count (`models.py:75`). Recency returns leaf-file count before limit (`service.py:991`), shallow returns immediate file-plus-folder entry count (`service.py:948`), and browse returns files plus synthesized folders (`service.py:1059`). | High | **Needs a change before merge.** Use view-specific DTOs or one stable meaning plus separately named counts. | +| **[Codex L2-F4] The claimed pagination tests are absent.** | The added fake implements `list_objects_shallow` but not `list_objects_page`. Added tests call `list_files` for Git-aware, count-only, and shallow cases (`test_mounts_file_ops.py:190` through `380`); no test calls `list_files_page`, passes a cursor, or checks `next_cursor`. There are also no new router, archive, mtime ZIP, or rollup tests. | High | **Needs a change before merge.** Add direct service tests and at least one router-level test for validation, access, response shape, and project scoping. | +| **[Codex L2-F5] The new JSON endpoint is missing from the generated client.** | `driveFlatFiles.ts:1` through `82` calls `/files/page` with raw axios and manually casts the response. This is a normal JSON route, not a binary route. CodeRabbit has already raised this guideline violation. | High | **Merge with a follow-up ticket only if explicitly tracked and accepted.** Prefer regenerating the client in this PR. | + +### [Codex] Lens 3: architecture, generality, and maintainability + +| Codex finding | Evidence and assessment | Confidence | Recommendation | +|---|---|---|---| +| **[Codex L3-F1] `list_files` is now a dispatcher for three independent algorithms.** | The method spans `service.py:787` through `1059` and selects behavior by optional-field presence. The branches differ in store primitive, filtering, result semantics, and cost. This structure caused the silent-combination and `total` contract problems in Lens 2. | High | **Needs a change before merge** at the method boundary. Extract explicit directory, recent-files, and full-browse helpers with typed inputs/results. | +| **[Codex L3-F2] Git-aware browsing is useful product policy embedded in a generic storage service.** | Keeping filtering close to the object-store walk avoids duplicate scans, but `MountsService` now owns pathspec parsing, rule scope, internal-file policy, cursor skipping, and hidden-file presentation. The behavior already differs by listing mode. | High | **Merge with a follow-up ticket** to extract a tested Git-aware filter/listing component after current correctness gaps are fixed. | +| **[Codex L3-F3] The recent-directory rollup misses its stated motivating case.** | `_rollup_recent_entries` requires a timestamp outside the directory because `_cohesive` returns `bool(gaps)` (`service.py:229` through `327`). A new mount containing only one cloned repository has no outside timestamp, so it does not roll up. Conversely, `max(gaps)` lets one distant outside timestamp classify a directory with a wide internal history as cohesive. No tests cover either case. | High | **Needs a change before merge.** Define only-batch, mixed old/new, equal-time, missing-mtime, and distant-neighbor examples first, then simplify or replace the heuristic. | +| **[Codex L3-F4] “Latest N” bounds the response, not backend work.** | The recency branch enumerates every surviving object, builds every `MountFile`, sorts, then applies `limit` (`service.py:950` through `1012`). The frontend acknowledges this full scan at `web/packages/agenta-entities/src/session/api/api.ts:470`. Exact `total` makes a full scan unavoidable with the current storage model. | High | **Merge with a follow-up ticket** if expected repositories are modest after ignore pruning. For large mounts, index/cache counts and recent metadata. Do not describe the route as bounded work. | +| **[Codex L3-F5] The ZIP streams HTTP output but buffers complete object bodies.** | `ObjectStore.get_object` reads a whole object into `bytes` (`storage.py:395`). `iter_archive_members` builds the complete work list, starts up to eight full-body reads, and holds those byte strings in tasks (`service.py:1198` through `1244`). A few multi-GB files can exhaust API memory. ZIP64 support does not make object reads memory-safe. | High | **Needs a change before merge.** Add a streaming object-body API and feed bounded chunks to `stream-zip`. Bound concurrency by bytes, not only file count. | +| **[Codex L3-F6] The advertised Gitignore cap does not bound a level batch.** | `_read_gitignore_specs` gathers every read concurrently and has no local cap (`service.py:671`). `_list_pruned_files` checks `len(specs) < 100` and then passes the whole level batch (`service.py:741` through `766`), so one level can exceed `_MAX_GITIGNORE_FILES`. The traversal semaphore limits active I/O, not task/frontier size. | High | **Needs a change before merge.** Slice remaining reads and use bounded worker iteration. | +| **[Codex L3-F7] Recreating `Minio` clients deserves measurement, not a speculative rewrite.** | `_client()` constructs a client for every adapter call (`storage.py:107`), and the new level walk increases calls. Current evidence does not show client construction is material relative to network latency. | Medium | **Merge with a follow-up profiling ticket** only if store telemetry shows it matters. | + +### [Codex] Lens 4: correctness, trust boundaries, and data handling + +| Codex finding | Evidence and assessment | Confidence | Recommendation | +|---|---|---|---| +| **[Codex L4-F1] The ignored-directory cursor jump can loop forever for valid Unicode filenames.** | `list_files_page` jumps to `ignored_dir + "/" + U+FFFF` and claims it sorts after every key (`service.py:1146`). U+FFFF is not the maximum Unicode scalar. A key such as `node_modules/😀.js` sorts after that sentinel in the lexical ordering the code relies on. The next page can return the same ignored object, set the same sentinel again, and repeat without progress. Agent-mounted filenames are not restricted by `validate_file_path`. | High | **Needs a change before merge.** Compute the lexicographic successor of the ignored prefix, assert strict cursor progress, and regression-test non-BMP characters. | +| **[Codex L4-F2] Archive entry paths are caller-controlled and can contain traversal segments.** | `prefix.strip("/")` leaves `../../outside` unchanged, and `zip_path = f"{pfx}/{rel}"` writes it directly (`service.py:1200` through `1222`). `ArchiveMount.prefix` and `path` have no constraints (`models.py:39`). Duplicate destinations are also accepted. | High | **Needs a change before merge.** Normalize every final archive path as relative POSIX, reject absolute paths, `.`/`..`, backslashes/control characters, and collisions, and constrain source paths. | +| **[Codex L4-F3] The response filename and archive mount ID are insufficiently typed.** | `mount_id: str` is manually passed to `UUID(...)`, so malformed JSON becomes a generic 500 (`models.py:43` and `router.py:675`). `filename` is interpolated into `Content-Disposition` without quote/control-character handling (`utils.py:119`). CodeRabbit has open comments for both and this review confirms them. | High | **Needs a change before merge.** Use `UUID`, a constrained non-empty mount list with a maximum, and RFC 6266/5987-safe filename encoding. | +| **[Codex L4-F4] The path allowlist rejects common repository paths required by lazy browsing.** | `validate_file_path` allows only `[\w. -]+` per segment (`service.py:109`). Agent-created paths such as Next.js `app/(auth)/[slug]`, npm-style `@scope`, and names containing `+`, `,`, or `#` can be listed but cannot be passed back to expand, page, read, or download. The new lazy flow sends `path` on each expansion. | High | **Needs a change before merge.** Preserve relative paths while rejecting absolute paths, traversal segments, NUL, and actual escape conditions. Add realistic repo-name tests. | +| **[Codex L4-F5] Gitignore behavior is not Git-compatible across nested rules or page mode.** | `_path_gitignored` returns on the first matching spec (`service.py:172`), so a deeper `.gitignore` cannot override a root file pattern for a kept directory. Page mode loads only root-to-request-path files and explicitly skips deeper ignore files (`service.py:1078`). Shallow child counts do not load each child's own `.gitignore`, so counts can disagree with expanded contents. | High | **Needs a change before merge** if `git_aware` promises Git behavior. Otherwise rename/document it as best-effort and add supported/unsupported contract tests. | +| **[Codex L4-F6] Page exhaustion and cursor context are underspecified.** | On exactly `limit` entries, the method returns a cursor without considering that `has_more` may be false (`service.py:1155` through `1164`). The next request can be empty although the model says `None` means exhausted. The raw key cursor is described as opaque but is not bound to mount, path, or filter flags. | High on the extra page; medium on binding | **Merge with a follow-up ticket** after adding direct page tests. Return `None` when exhaustion is known and document same-query reuse. | +| **[Codex L4-F7] Unbounded inputs multiply resource use.** | Archive requests have no mount-count or total-size guard. Plain `GET /files?order=...&limit=...` has no limit maximum and scans the full tree. Raw `limit=0` does not use `_COUNT_CAP` because that cap is only passed to the Git-aware descent. `.gitignore` bodies are read fully before parsing (`service.py:638`). | High | **Needs a change before merge** for archive cardinality, object-body streaming, and the advertised ignore cap. Add quotas/timeouts for export work and a `.gitignore` size policy. | +| **[Codex L4-F8] Project isolation, permission choice, and mtime units are correct.** | Every archive mount resolves with the request project ID (`service.py:583` and `1200`). Both routes are read-only and use `VIEW_MOUNTS`. New store methods produce epoch milliseconds (`storage.py:331`, `361`, `390`), and ZIP conversion divides by 1000 (`utils.py:97`). No sibling unit mismatch was found. | High | **Merge as-is for these aspects.** Keep an mtime regression test because failures occur after response headers. | + +### [Codex] Verification record + +- **[Codex]** Fresh remote state was fetched. The reviewed head was + `3ac6af25160a4b2546c682137f9d3ed5865fabe0`. +- **[Codex]** `git diff --check origin/main...origin/fe-refactor/drive-surfaces` reported no + backend whitespace errors. +- **[Codex]** Live checks showed Python format, Python lint, API unit, Railway API unit, + Railway API integration, and Railway API acceptance tests passing. +- **[Codex]** The overall PR was not fully green because web unit/acceptance checks were failing. + This backend review did not diagnose those frontend failures. +- **[Codex]** The lock diff adds only `pathspec 0.12.1` and `stream-zip 0.0.84`. It does not update + unrelated locked package versions. +- **[Codex]** No code changed and no GitHub review comments were posted. The only workspace edit is + this tagged report appended to the supplied handoff. + + diff --git a/docs/design/agent-workflows/scratch/pr-5400-drive-mounts-review-report.md b/docs/design/agent-workflows/scratch/pr-5400-drive-mounts-review-report.md new file mode 100644 index 0000000000..11cb214757 --- /dev/null +++ b/docs/design/agent-workflows/scratch/pr-5400-drive-mounts-review-report.md @@ -0,0 +1,645 @@ +# PR #5400 backend review — mounts/files (drive surfaces) + +> **Reviewed by Claude (Fable 5).** This report was produced by Claude Code running the +> Fable 5 model: four independent Fable 5 review agents, one per lens from the review +> brief, plus a synthesis pass that cross-checked overlapping findings and re-verified +> the headline bug directly against the branch. Reviewed at branch +> `fe-refactor/drive-surfaces`, commit `3ac6af25160a4b2546c682137f9d3ed5865fabe0` +> (the open PR head), on 2026-07-20. All line numbers refer to the branch version of +> each file, read via `git show`, never the local working tree. +> +> Brief: `pr-5400-drive-mounts-review-handoff.md` (same folder). +> +> **Revision (2026-07-20, Claude Fable 5):** cross-reviewed against Codex's independent +> report on the same PR. Every Codex finding not already in this report was re-verified +> against the branch before adoption. Result: one new must-fix finding adopted (the path +> validation allowlist, item 11), two recommended items adopted (items 12-13), one error +> in this report's original Lens 4 corrected (the plain count path is NOT bounded by +> `_COUNT_CAP`), and one Codex position overruled with library-source evidence (the +> store-connection cost). Details in "Cross-review against Codex" below. + +## Verdict + +**Needs changes before merge, but they are contained and cheap.** The foundations are +sound: tenant scoping is correct (every caller-supplied `mount_id`, including each +entry in the bulk archive body, resolves through the project-filtered DAO, so no path +reaches another project's mount), cursors cannot escape the mount prefix, permission +checks match the siblings, layering direction holds, and no service raises +`HTTPException`. For code written end to end by a frontend engineer, this would +largely pass a "written the way we write backend code here" test. + +Three things block merge: + +1. **A verified infinite-loop bug** in the new cursor pagination (Finding 4.1 below), + reachable with realistic filenames. +2. **The archive endpoint validates after the 200 headers are sent**, so a bad request + returns a silent corrupt zip — the exact failure class this PR itself diagnosed and + fixed for the mtime bug (Findings 1.2 and 4.2, found independently by two lenses). +3. **The PR's central new feature ships untested, and the PR body says otherwise.** + No test touches `list_files_page`, the cursor jump, the rollup heuristic, or the + archive path; the test fake does not even implement `list_objects_page` + (Finding 2.4). The infinite loop in item 1 lives exactly in this untested gap. +4. **The path validation allowlist breaks the PR's own headline flow on real repos** + (adopted from Codex, verified here — Finding 4.9). `validate_file_path` only + accepts `[\w. -]` per segment, so folders agents actually create — Next.js route + groups like `app/(auth)/[slug]`, npm scopes like `@scope/pkg`, names with `+`, + `,`, `#` — appear in listings but return 422 on every expand, page, read, or + download. The old UI fetched the whole tree in one call with no `path`, so this + rarely fired; the new lazy flow sends `path` on every folder expansion, making + the same regex a first-class product bug. + +A second tier of changes is strongly recommended in the same pass because each is a +one-or-two-line change now and a breaking API change later: rename +`POST /files/archive` (it collides with the codebase's soft-delete "archive" verb), +type `ArchiveMount.mount_id` as `UUID`, constrain `depth` and `order` so invalid +values 422 instead of silently selecting the most expensive code path, and wrap +`list_files_page`'s tuple return in a named DTO. + +Everything else is follow-up-ticket material, consolidated at the end. + +--- + +## Consolidated pre-merge changes + +**Must fix:** + +| # | Change | Where | Found by | +|---|---|---|---| +| 1 | Fix the ignored-directory cursor jump: skip a prefix by incrementing its last byte (`dir/` → `dir0`), not by appending U+FFFF; add a forward-progress guard to the loop (break if `start_after` did not strictly increase). | `service.py:1149`, loop at 1124-1164 | Lens 4 (traced and verified); Lens 3 flagged the same edge independently; synthesis re-verified the loop | +| 2 | Resolve every requested mount (and build the archive work list) eagerly, before constructing the `StreamingResponse`, so `MountNotFound` / `MountStorageUnavailable` reach the decorators and return a real 404/503. `iter_archive_members` already separates the work-list phase from the byte-read phase, so the seam exists. | `router.py:662-679`, `utils.py:72-123`, `service.py:1182-1248` | Lens 1 and Lens 4, independently | +| 3 | Add fake-backed unit tests for `list_files_page`: plain paging across page boundaries, and the git-aware ignored-directory jump (which pins the fix for item 1). Requires implementing `list_objects_page` on the existing in-memory fake. | `api/oss/tests/pytest/unit/test_mounts_file_ops.py` | Lens 2; Lens 4 concurs | + +**Strongly recommended in the same pass (cheap now, breaking or blind later):** + +| # | Change | Where | Found by | +|---|---|---|---| +| 4 | Rename `POST /files/archive` to a non-lifecycle verb (`/files/export`, `/files/zip`, ...). "Archive" is this router's own soft-delete verb three registrations below, and the collision is what forces the route-ordering hack. Renaming after the frontend ships against it is a breaking change. | `router.py:197` vs `router.py:205` | Lens 2 | +| 5 | Type `ArchiveMount.mount_id` as `UUID` and drop the manual `UUID(...)` cast; today a typo returns a generic 500 instead of a 422. | `models.py:43`, `router.py:676` | Lenses 1, 2, 4, independently | +| 6 | Constrain `depth` (`le=1`, or reject unsupported values). The router advertises `ge=1` but the service only implements `depth == 1`; `depth=2` silently falls through to the full-tree browse listing, the most expensive branch — the opposite of what the parameter implies. | `router.py:515` vs `service.py:845` | Lenses 2, 3, 4, independently | +| 7 | Make `order` a `Literal`/enum (`recent`/`name`/`path`); any other string currently returns the flat view unsorted with no error. | `router.py`, `get_mount_files`; `service.py:994-1012` | Lenses 2 and 4 | +| 8 | Wrap `list_files_page`'s return in a named `MountFilePage` DTO. `api/AGENTS.md` names the bare-tuple return verbatim as an anti-pattern, and every sibling method in this service returns a DTO. Ten-line change. | `service.py:1061-1071`, unpacked at `router.py:569` | Lens 1; noted by Lenses 2 and 3 | +| 9 | Unit tests for `_rollup_recent_entries` — a pure `List[MountFile] → List[MountFile]` function with subtle boundary semantics and zero tests. Behaviors to pin are listed in Finding 3.3. | `service.py:229-327` | Lens 3 | +| 10 | Validate the archive body's path-shaped inputs: `validate_file_path` on non-empty `ArchiveMount.path` and `prefix`, sanitize `..` segments when building zip entry names, and restrict `filename` (it currently flows raw into `Content-Disposition`, where a quote breaks the header and any non-latin-1 character turns into a 500). | `models.py:39-53`, `service.py:1200-1222`, `utils.py:122` | Lens 4 | + +**Added after cross-review with Codex (each verified against the branch before adoption):** + +| # | Change | Where | Tier | +|---|---|---|---| +| 11 | Relax `validate_file_path`'s segment allowlist (`[\w. -]+`, `service.py:43`) to reject only genuinely unsafe input (absolute paths, `.`/`..` segments, NUL, control characters) instead of allowlisting characters. Today `( ) [ ] @ + , # ~` all 422, so listed folders like `app/(auth)` cannot be expanded, paged, read, or downloaded. Add tests with realistic repo names. Note items 10 and 11 pull opposite directions on the same function — do them together. | `service.py:43,109`; every `path` call site in `router.py` | **Must fix** (Finding 4.9) | +| 12 | Give `total` one meaning, or per-view fields. Verified: the same response field is pre-limit leaf-file count in recency mode (`service.py:991`), immediate files-plus-folders in shallow mode (`:948`), and recursive files-plus-synthesized-folders in browse mode (`:1059`). | `models.py:75`, three `list_files` branches | Recommended (Finding 2.6) | +| 13 | Apply `_COUNT_CAP` to the plain (non-git-aware) count-only path. Verified: the cap is only passed to the git-aware descent; the plain branch runs a full uncapped enumeration (`service.py:964-969`). This corrects an error in this report's first revision, which claimed the count-only path was bounded. | `service.py:951-993` | Recommended (Finding 4.8, corrected) | + +--- + +## Lens 1 — Organizational fit + +**Summary.** A much better fit than the frontend-author premise would predict. The +brief's quotes from `api/CLAUDE.md` were verified accurate and complete against the +real file. Router registration style, `operation_id`s, keyword-only handler params, +the decorator stack, `count`-bearing response envelopes, DTO placement in +`core/mounts/dtos.py`, and layering direction all match the convention exactly. No new +code raises `HTTPException` from a service; all new service-side failures reuse the +existing `MountError` family. The deviations are real but bounded. + +**Findings.** + +- **1.1 — Tuple return violates the typed-DTO rule** (high confidence, change before + merge). `list_files_page` returns `Tuple[List[MountFile], Optional[str]]` + (`service.py:1061-1071`); the convention document names this exact shape as an + anti-pattern ("Do NOT return tuples... use a named DTO"). The author demonstrably + knows the pattern — the PR itself added `total`/`total_capped` to the `MountFileList` + DTO for the sibling method. → Pre-merge item 8. +- **1.2 — The archive endpoint's exception decorators are decorative** (high + confidence, change before merge). The handler returns a `StreamingResponse` + immediately; mount resolution happens lazily inside the generator after the 200 + headers are out, so `MountNotFound` never reaches `@handle_mount_exceptions()`. + Same code, same conclusion as Lens 4's Finding 4.2. → Pre-merge item 2. +- **1.3 — `ArchiveMount.mount_id: str` + bare `UUID()` cast → 500 instead of 422** + (high confidence). → Pre-merge item 5. +- **1.4 — Eight loose GET query params instead of the parse-and-merge query-object + pattern** (high confidence it deviates; medium severity). The mounts domain itself + uses `merge_mount_query` for mounts; the file-ops routes now take loose `Query(...)` + params passed positionally into a nine-parameter service signature. Follow-up, + bundled with Lens 3's method-split ticket (the two refactors belong together). +- **1.5 — Raw store exceptions pass through the service untranslated** — a + pre-existing domain-wide gap the PR widens without worsening: MinIO `S3Error` / + network failures on the new call sites land in `intercept_exceptions` as generic + 500s, so "bucket briefly down" reads as a code bug, and the 503 mapping for + `MountStorageUnavailable` only fires when the bucket is unconfigured. Follow-up + ticket at the domain level. The bare-`except` gitignore swallowing, by contrast, is + a deliberate controlled default with a stated rationale and is acceptable. +- **1.6 — Comment density and voice** (judgment call, fine as-is). A substantial + fraction of the comments are genuinely load-bearing "why" (the visited-set hang + guard, the sentinel rationale, the ms-vs-s note). But density is far above the + documented "one terse line" bar, several comments restate the code, and the new + CAPITALIZED-emphasis tic (ONE, FLOOR, NEVER, STREAM) is the one place the code + visibly reads as a different hand. Mitigating: the pre-PR mounts file already + speaks the same heavily-annotated dialect. Trim opportunistically. +- **1.7 — Minor signature-style inconsistencies** (low severity, fine as-is): some + new helpers take positional args where siblings use the house keyword-only `*` + style; the new long signatures drop the `#` group-separator convention. + +**Lens 1 recommendation:** merge after items 2, 5, 8; the rest rides follow-ups. + +## Lens 2 — Endpoint organization, interface design, testing practice + +**Summary.** The pagination and endpoint-shape choices are more defensible than the +brief suggested; the genuine problems are the "archive" name collision, three +loosely-validated parameters with silent fallbacks, and a testing story that the PR +body misstates. + +**Findings.** + +- **2.1 — Hand-rolled cursor pagination is defensible, not drift** (high confidence, + fine as-is). `Windowing`'s cursor token is typed `next: Optional[UUID]` + (`sdks/python/agenta/sdk/models/shared.py:203`) with time-range/sampling fields + built for database rows — it structurally cannot carry an object-store key without + changing a shared SDK model every entity domain depends on. More importantly, the + codebase already has a second established idiom for non-database listings: the tools + and triggers catalog routers paginate with GET + query params + a plain-string + cursor (`tools/router.py:452`, `triggers/router.py:764`, + `core/gateway/catalog/dtos.py:53`). The mounts listing follows that idiom. Only + cosmetic drift: the catalogs call the field `cursor`, this PR calls it + `next_cursor`. Align in a follow-up if anyone cares. +- **2.2 — GET-with-params is consistent with the domain; the problem is mode + multiplexing with silent edges** (high confidence). `order` accepts any string and + silently returns unsorted on a typo; `depth=2` silently selects the most expensive + branch; `limit=0` is an undiscoverable count-only sentinel that the sibling paged + endpoint rejects (`ge=1`) — two adjacent endpoints give the same value opposite + meanings. → Pre-merge items 6, 7; `limit=0` stays with a follow-up note. +- **2.3 — `POST /files/archive`: body shape precedented, name wrong** (high + confidence, change before merge). Bulk POST-body operations exist (evaluations' + `EvaluationRunIdsRequest`), and per-item `prefix`/`path` justifies a list of + objects. But "archive" is the codebase's soft-delete lifecycle verb — including + `archive_mount` in this same router — and the overload is what forces the + registration-order workaround. A rename (`/files/export`, `/files/zip`) removes the + ambiguity and the ordering fragility together, and gets strictly more expensive + after the frontend (`driveMedia.ts:260`) ships against it. → Pre-merge items 4, 5. +- **2.4 — The tests do not cover what the PR body says they cover** (high confidence, + change before merge). The nine added tests are a thoughtful suite for the + `list_files` shallow and git-aware branches (pruning, raw-by-default, the + folder-marker hang regression, count-only, `with_counts`, the + `.gitignore`-survives-`.*` edge). But `list_files_page`, the cursor jump, + `_rollup_recent_entries`, and the archive/mtime path have zero coverage — verified + by grep — and the fake store does not implement `list_objects_page`, so the paged + method cannot currently be exercised at all. The PR body's "unit tests for the + paged listing" is inaccurate. The domain's acceptance suite + (`acceptance/mounts/test_mounts_basics.py`) also gained nothing for either new + endpoint. → Pre-merge item 3; remaining debt (rollup if not done, archive/mtime + regression test, acceptance coverage) becomes a real tracked ticket. +- **2.5 — Fern client gap: precedent claim is half true** (high confidence, + follow-up). The axios precedent holds for binary routes, but `/files/page` is a + JSON listing endpoint, and JSON mount listings go through the generated client + today; `web/AGENTS.md` states the rule plainly. The PR also hand-edits two files + under `agenta-api-client/src/generated/`, so the client directory no longer matches + any regeneration run. One real Fern regeneration (after the rename in item 4) plus + moving `driveFlatFiles.ts:71` onto the generated client, filed as a ticket as part + of landing this PR. + +- **2.6 — `total` means three different things** (high confidence; adopted from + Codex L2-F3, verified). The same response field is documented as the full file + count (`models.py:75`) but is computed as pre-limit leaf-file count in recency + mode (`service.py:991`), immediate files-plus-folders in shallow mode (`:948`), + and recursive files-plus-synthesized-folders in browse mode (`:1059`). The UI + badge consumes whichever mode it queried, so nothing breaks today, but the field + is public contract and the ambiguity compounds the mode-multiplexing problem + (2.2/3.1). → Pre-merge item 12 (either one stable meaning or per-view fields). + +**Lens 2 recommendation:** merge after items 3, 4, 5, 6, 7, 12; two follow-up tickets +(test debt, Fern regen). The pagination design itself needs no change. + +## Lens 3 — Architecture + +**Summary.** Better engineered than a typical cross-discipline patch: pure-function +helpers, real invariants documented, and two genuine primitives (the pruned descent, +the cursor pagination). The weaknesses concentrate in `list_files`'s +three-views-in-one-method shape, ~500 lines of git-view policy living inside +`MountsService`, and the rollup heuristic being clever, untested, and silent in its +own motivating scenario. + +**Findings.** + +- **3.1 — `list_files` (`service.py:787-1059`) is three unrelated views dispatched by + parameter shape** (high confidence). The branches share about ten lines of setup and + nothing else. The mode matrix has silent edges (the `depth` hole → pre-merge item 6; + `depth=1&limit=5` silently discards `depth`; `with_counts`/`include_gitignored` + honored in some branches, ignored in others — documented honestly in the docstring, + which is itself the symptom). The PR proves the author knows the right pattern: + `list_files_page` is exactly the separate-named-method shape, and it is the + cleanest view as a result. Follow-up ticket: split into + `list_files_shallow` / `list_files_flat` / browse behind the same wire endpoint, + together with Lens 1's query-object refactor (1.4). +- **3.2 — Git-awareness placement** (medium-high confidence, follow-up). The brief's + "wrapping view / decorator" alternative is not actually available: the valuable + behavior is never enumerating ignored subtrees, which requires cooperating with the + store-access strategy (prune before listing, jump the cursor). So coupling into the + same call path is right — but not into the same file and class. About 500 of the + file's 1345 lines are git/view policy, mostly module-level pure functions. + Extract to `core/mounts/git_view.py`; `MountsService` keeps its signatures and + delegates. Also note: "git-aware" is quietly two behaviors — the recency descent + honors nested `.gitignore` files while the paged view honors only the ancestor + chain (`service.py:1080-1083`), so the same file can be hidden in one view and + visible in the other. A named module with a shared spec-loading contract would + surface that drift. Cross-review sharpened this with two verified specifics + (Codex L4-F5): `_path_gitignored` returns on the first matching spec with specs + ordered shallow-to-deep (`service.py:209-210`), so a deeper `.gitignore`'s + `!re-include` rule is never consulted — negation works within one file (pathspec + handles it) but not across files, and the docstring at `:185-186` admits it; and + `with_counts` child counts use only the parent's ancestor-chain specs + (`service.py:854-865, 907-935`), never the child directory's own `.gitignore`, so + a folder's badge count can disagree with what expanding it shows. Whether this is + a bug or a documentation task depends on a product decision: does `git_aware` + promise Git-compatible semantics, or is it a best-effort presentation filter? That + question goes to the product owner; the follow-up ticket should carry the answer. +- **3.3 — `_rollup_recent_entries` (`service.py:229-327`): algorithm correct as + implemented, untested, and fails its own motivating case** (high confidence). + Traced line by line; the bisect logic and the generous `max(gaps)` single-linkage + choice are correct for what they compute. Degenerate cases: **a mount whose entire + visible history is one batch (the drive opened right after the motivating + `git clone`) has no outside activity on either side, `gaps` is empty, and nothing + rolls up at any level — the recency list floods with leaves exactly when the + feature was meant to fire.** Amplified by pipeline order: hidden paths and runner + internals are filtered before rollup, so dotfiles cannot serve as the outside + anchor. Consequence is cosmetic (limit-capped), but deserves a fallback (product + call, follow-up). Handled safely: untimed leaves (block collapse conservatively), + two-file directories (may collapse across months — surprising, not harmful), + all-files-one-mtime (no rollup, consistent), interleaved sibling directories (both + collapse — desirable). One edge worth a test: a key that is both file and directory + drops the file row silently (`service.py:320`). Tests to pin (pre-merge item 9): + clone-then-edit collapses to the top directory; old-plus-fresh directory does not + collapse; untimed leaf blocks collapse; single-batch produces no rollup (or the + chosen fallback); shallow-to-deep resolution picks `repo/` over `repo/web/`. +- **3.4 — Constants** (high confidence, follow-up). All four are the right order of + magnitude. Three composition problems: `_ARCHIVE_READ_CONCURRENCY` bounds file + *count*, not *bytes* — eight 1 GB bodies is 8 GB of process memory (the knob + measures the wrong dimension; resource-bounds face is Lens 4's Finding 4.5); + `_MAX_GITIGNORE_FILES` is a soft cap, checked before a whole level's batch extend + (`service.py:765-766`), so one wide level sails past it; the semaphore is + per-request with no global backpressure. Promote `_LIST_CONCURRENCY` and + `_ARCHIVE_READ_CONCURRENCY` to `env.py` per the repo's env-config convention + (precedent: `AGENTA_RUNNER_CONCURRENCY_LIMIT`); the two algorithmic backstops stay + module constants. +- **3.5 — The pruned descent generalizes honestly** (high confidence, fine as-is). + Wall-clock is depth-dominated with bounded fan-out; real repos are rarely deeper + than 10-15 levels. Wide directories degrade acceptably (miniopy pages internally at + ~1000 keys with a fresh connection per page; the level barrier waits on the + straggler). The genuinely unbounded case is a huge tree with no `.gitignore` — + fully enumerated by the non-count-only recency path, same as before the PR. Ticket: + an overall time/count budget on the non-count-only descent. +- **3.6 — The `_client()` pattern now matters** (high confidence on mechanics, + verified in miniopy-async 1.21.3 source; medium on latency arithmetic). + The `Minio` object is cheap; the real cost is that **every operation opens a fresh + `aiohttp.ClientSession`** (new TCP + TLS, zero keep-alive): `get_object` because + `storage.py:404` makes a session per call, listings because miniopy hardwires a + session per result page. Shallow-listing a 50-directory tree with counts ≈ 53 fresh + connections (~200-300 ms of pure handshake against remote TLS S3). Archiving a + 5000-file mount ≈ 5000 fresh connections, ≈ 47 seconds of handshake time at 8-way + concurrency — connection setup dominates the download for small-file mounts. + This PR is the point where the pattern starts to matter. Pragmatic fix, prioritized + follow-up: one long-lived session held by `ObjectStore` for `get_object` (which + already accepts one) fixes the archive path with a small `storage.py`-only change; + the listing paths cannot reuse a session without patching miniopy — leave them. + Codex took the opposite position ("client construction is cheap; measure before + changing"), which conflates two costs: constructing the `Minio` object is indeed + cheap, but that was never the claim. Re-verified in the installed miniopy-async + 1.21.3: `get_object` *requires* a caller-supplied session (`api.py:1554-1558`) and + `storage.py:404` opens a fresh `aiohttp.ClientSession` per read — a fresh session + is a fresh TCP (and TLS, when https) connection, not object construction. The + claim stands. Codex's measure-first caution retains one grain of merit: the + bundled local SeaweedFS speaks plain http on a local socket, where the handshake + cost is negligible — the 47-second figure applies to remote TLS S3. + +**Lens 3 recommendation:** merge after items 6 and 9; follow-up tickets for the +method split + module extraction, the shared store session, and the constants. + +## Lens 4 — Correctness and trust boundaries + +**Summary.** Tenant scoping, cursor confinement, permission fit, error translation on +the non-streaming paths, and the unit-mismatch sweep all check out sound. One genuine +hang bug, one repeat of the PR's own silent-failure class, and a cluster of +unvalidated archive-body inputs. + +**Findings, ranked.** + +- **4.1 — Infinite loop in `list_files_page`** (high confidence; verified by the + lens agent by running both enabling facts, then re-verified in synthesis by tracing + the branch code directly — and Lens 3 flagged the same edge independently). + The ignored-directory jump sets `start_after = f"{mount_base}{ignored_dir}/" + + "￿"` (`service.py:1149`) assuming U+FFFF sorts after every key under the + directory. S3-compatible stores order by UTF-8 bytes; U+FFFF is `EF BF BF`, and + every astral-plane code point (emoji, CJK Extension B) encodes from `F0` — greater. + A key like `node_modules/𠀀file.txt` therefore sorts after the sentinel; the loop + lists it, finds its ancestor ignored, resets `start_after` backwards to the same + sentinel, and `continue`s — same page forever, no exit condition, one store listing + per iteration. Both enabling facts hold: astral ideographs pass + `validate_file_path` (Python's `\w` is Unicode-aware), and agents can also write + arbitrary keys (including literal U+FFFF) through the prefix-scoped signed + credentials, bypassing validation entirely. Trigger is mundane: an agent clones a + repo with one astral-named file in a gitignored area; the user opens the Files + drawer; the request never returns. → Pre-merge item 1. Fix: increment the prefix's + last byte (`dir/` → `dir0`) — sorts after every key under `dir/` in byte order with + no exceptions — plus a forward-progress guard on the loop. +- **4.2 — Archive validation after the 200 headers** (high confidence, change before + merge). Same finding as 1.2, from the correctness side: a request naming a missing + mount, or arriving while storage is down, gets HTTP 200 with a truncated or + zero-byte zip and no visible error — the same symptom profile as the mtime bug this + PR fixed, documented in the PR's own comment at `utils.py:97-99`. Mid-stream read + errors are unavoidable with streaming; the up-front resolution is not. + → Pre-merge item 2. +- **4.3 — Unvalidated archive body inputs** (high confidence on the gaps; medium on + impact since the requester is authenticated and mostly harms their own download). + `ArchiveMount.path` skips `validate_file_path` (inconsistent, though store keys are + literal so `..` cannot escape the tenant boundary); `prefix` flows raw into zip + entry names, so entries like `../../x` are mintable ("zip-slip" for whoever + extracts the archive), and keys written via signed credentials can carry `..` into + `rel` unsanitized; `filename` flows raw into `Content-Disposition` (quote breakout; + non-latin-1 → 500). → Pre-merge item 10. +- **4.4 — Malformed `mount_id` → generic 500** (high confidence). Same as 1.3/2.3b. + → Pre-merge item 5. +- **4.5 — No bound on archive breadth; bodies buffered whole, eight in flight** + (high confidence on mechanics, follow-up). Unbounded, duplicable `mounts` list — + each entry a DB fetch plus a full recursive listing; peak memory ≈ nine times the + largest file (whole-body `get_object` reads + 8-deep prefetch); the work list + front-loads the full enumeration the listing paths were built to avoid, so + time-to-first-byte on a huge drive is the old full scan. Ticket: cap and dedupe + `mounts` (the product needs two), and a bytes-based prefetch bound or streaming + per-file reads. Composes with 3.4. +- **4.6 — Gitignore handling: unbounded size, unbounded per-batch concurrency, soft + cap** (high confidence; triggering trees are pathological but agent-constructable). + Each `.gitignore` is fetched whole with no size check before parsing (the bare + `except` catches parse failures, not cost); `_read_gitignore_specs` gathers its + batch with no semaphore; the cap is checked only between batches. Ticket: ~1 MB + per-file ceiling, truncate batches to the remaining cap, reuse the + `_LIST_CONCURRENCY` semaphore. The silent-skip failure mode itself is the right + call. +- **4.7 — Synchronous regex matching and rollup on the event loop** (medium + confidence, fine for now). files × depth × specs regex work inside an async + handler; on big trees this stalls the worker. Note in 4.6's ticket: `to_thread` or + memoize per-directory verdicts. +- **4.8 — `GET /{mount_id}/files` input notes** (high confidence; **corrected after + cross-review**). The missing upper bound on `limit` is defensible: `limit` only + slices after enumeration, so a huge value costs nothing extra. On `limit=0` + (count-only), this report's first revision claimed the path was bounded by + `_COUNT_CAP`; that is only true in git-aware mode. The cap is passed solely to + `_list_pruned_files` (`service.py:955-961`); the plain branch calls the full + recursive listing with no cap and hardcodes `truncated=False` + (`service.py:964-969`), so a raw count on a huge tree enumerates everything. + → Pre-merge item 13. The `order`/`depth` gaps → items 6, 7. +- **4.9 — The path-segment allowlist makes listed folders un-openable** (high + confidence; adopted from Codex L4-F4, verified here including regex execution + against real names). `validate_file_path` uses `_SEGMENT_RE = r"[\w. -]+"` + (`service.py:43`), which rejects `( ) [ ] @ + , # ~` and quotes. Verified rejects: + `app/(auth)/[slug]/page.tsx`, `@scope/pkg/index.js`, `c++.md`, `a,b.txt`, + `notes#1.txt`, `~backup`. Listings surface such names unfiltered (agents write + through prefix-scoped signed credentials, bypassing validation), but every + round-trip endpoint — expand (`depth=1` with `path`), page, read, download, write, + delete — validates `path` and returns 422 via `handle_mount_exceptions` + (`router.py:64-68`). So the file browser shows a folder it can never open. The + regex predates the PR, but the PR's lazy-browse flow is what round-trips `path` on + every expansion, promoting a latent restriction into a headline-flow bug. + → Pre-merge item 11. Fix direction: switch from an allowlist to rejecting the + genuinely unsafe (absolute paths, `.`/`..` segments, NUL, control characters) — + the exact opposite adjustment from item 10's tightening, so design both at once. +- **4.10 — Paged-cursor polish: trailing empty page, and cursors unbound to their + query** (high confidence, follow-up; adopted from Codex L4-F6, verified). When a + page fills to exactly `limit`, the code sets `next_cursor` and breaks without + consulting `has_more` (`service.py:1156-1160`), while the response model documents + `None` as the exhaustion signal (`models.py:88`) — so an exactly-full final page + costs the client one extra empty round trip. Separately, the cursor is a raw + mount-relative key with no binding to `path` or the git flags; replaying it with + different parameters silently returns wrong or empty results rather than an error. + Both are minor; fold into the paged-listing tests and follow-up ticket 8. + +**Explicitly verified sound:** tenant scoping (`_resolve_mount` → +`mounts_dao.fetch_mount` filters on `project_id` and `mount_id`, `dao.py:97-116`); +cursor confinement (re-prefixed onto the mount base, used only as `start_after` +against the mount's own prefix); `VIEW_MOUNTS` consistency (no finer-grained export +permission exists; RBAC here is project-scoped, so bulk read within the caller's +project is modeled consistently); error translation on the non-streaming endpoints +(404/422/503 via `handle_mount_exceptions`, no raw store text to the client); +the unit-mismatch sweep (all three listing methods produce mtime uniformly in epoch +milliseconds; the single seconds boundary now divides by 1000; `ZIP_AUTO` is fed +actual byte lengths — no sibling of the fixed mtime bug exists). + +**Lens 4 recommendation:** needs the two small pre-merge fixes (items 1, 2), with +items 5 and 10 folded into the same pass; the rest are follow-up tickets. + +--- + +## Cross-lens overlaps (same code, different angles) + +- **Deferred archive resolution** — Lens 1 (convention: decorators bypassed) and + Lens 4 (correctness: silent corrupt zip) hit `stream_mounts_archive` / + `iter_archive_members` independently and agree on the fix. +- **U+FFFF sentinel** — Lens 3 (architecture: the ordering assumption should live + next to the store's ordering contract in `storage.py`) and Lens 4 (correctness: + infinite loop). Lens 4's deeper trace supersedes Lens 3's "degrades" reading; + synthesis re-verified the loop. Lens 3's placement point stands for the fix. +- **`depth`/`order` silent fallbacks** — Lens 2 (interface contract), Lens 3 + (mode-matrix symptom), Lens 4 (resource surprise) — three angles, one fix. +- **`mount_id: str`** — Lenses 1, 2, and 4 found it independently. +- **The test gap** — Lens 2 (the PR body misstates coverage), Lens 3 (the rollup is + the code most likely to be "improved" blind), Lens 4 (the infinite loop lives in + the untested code). Three lenses converge: this is the PR's weakest point. +- **Archive resource bounds** — Lens 3 (the knob measures files, not bytes) and + Lens 4 (no cap on mounts; ~9× largest-file memory) are two faces of one ticket. + +## Cross-review against Codex's independent report + +Codex reviewed the same PR head independently and reached the same overall verdict +(needs changes before merge). The two reviews converged, without seeing each other, +on every major finding: the U+FFFF infinite loop, the archive endpoint validating +after the 200 headers, the untested paged listing (both noticed the fake store lacks +`list_objects_page`), the tuple return, `mount_id: str`, the unsanitized +`filename`/`prefix`, the mode-multiplexing of `list_files`, the rollup heuristic +missing its motivating single-batch case, the soft gitignore cap, and the +defensibility of the hand-rolled cursor over `Windowing`. That level of independent +agreement raises confidence in all of it. Codex also reports useful context this +review did not gather: backend CI (format, lint, unit, integration, acceptance) is +green while web unit/acceptance checks are failing, and CodeRabbit already has open +comments on the `mount_id` typing and `filename` header findings. + +**Adopted from Codex after verification** (each re-verified against the branch, +including running the actual regex, before adoption): the path-validation allowlist +bug (4.9 / item 11 — the most valuable thing Codex found, and a real miss in this +report's first revision), the three meanings of `total` (2.6 / item 12), the +uncapped plain count path (4.8 corrected / item 13), the cross-file gitignore +negation gap and the `with_counts` mismatch (folded into 3.2), and the paged-cursor +polish items (4.10). Also adopted as a refinement of item 8: Codex's point that the +archive parameters travel through the layers as anonymous `(UUID, str, str)` tuples — +when item 8's `MountFilePage` DTO is added, give the archive path a named +`MountArchiveSource(mount_id, source_path, archive_prefix)` in the same pass. + +**Where the two reviews still disagree, and this report's position:** + +- **Store connection cost (Codex L3-F7).** Codex recommends measuring before + changing, characterizing the concern as client construction. Verified against the + installed library source: the per-call cost is a fresh TCP/TLS connection, not + object construction, and `get_object` already accepts the shared session that + would fix it (see 3.6). Position unchanged: prioritized follow-up ticket, no + measurement gate needed for a change this small — though the pain is specific to + remote TLS stores, not the bundled local SeaweedFS. +- **Archive memory (Codex L3-F5).** Codex elevates streaming object-body reads to + a merge blocker; this report keeps it a prioritized follow-up (4.5 / ticket 3), + because a streaming read API is a genuinely larger change to `storage.py` and its + callers. **Resolved 2026-07-20 by the product owner (Mahmoud): very large files + are out of scope for "download all."** The follow-up ticket stands; nothing moves + to pre-merge. When ticket 3 is picked up, also soften the `>4 GB archives are + handled` claim in `stream_mounts_archive`'s docstring so the code stops promising + what the product does not. +- **Rollup remedy (Codex L3-F3).** Codex recommends simplifying or replacing the + heuristic pre-merge; this report asks only for pinning tests pre-merge (item 9) + and treats the single-batch fallback as a product decision (ticket 7). Replacing + an algorithm under review pressure, without tests to define intended behavior + first, is the wrong order of operations. + +**Product questions surfaced by the cross-review** (they decide the severity tier +of existing findings, and neither is answerable from the code): + +1. **What drive/file sizes must "download all" support?** — **ANSWERED 2026-07-20 + by Mahmoud: very large files are out of scope.** The memory findings (4.5, + Codex L3-F5) stay a follow-up ticket; no pre-merge work. +2. **Does `git_aware=true` promise Git-compatible ignore semantics, or is it a + best-effort presentation filter?** — **OPEN.** The implementation is currently + the latter (no cross-file negation, two different scopes across views, counts + that can disagree with expansion). If best-effort is the intent, document and + possibly rename it; if Git compatibility is the promise, the gaps in 3.2 become + bugs. Does not block any pre-merge item; blocks only follow-up ticket 8's scope. + +## Consolidated follow-up tickets + +1. **Split `list_files` into named per-view service methods**, extract git-view + policy into `core/mounts/git_view.py`, and adopt the parse-and-merge query-object + pattern for the file-ops routes (1.4 + 3.1 + 3.2). Includes unifying the two + gitignore scopes behind one named contract. +2. **Shared `aiohttp` session on `ObjectStore` for `get_object`** — first-order cost + for the archive path on remote stores (3.6). Prioritize before a real + multi-thousand-file mount hits cloud. +3. **Resource bounds:** cap + dedupe the archive `mounts` list; bytes-based prefetch + bound or streaming per-file reads; gitignore per-file size ceiling, hard batch + cap, semaphore on batch reads; a time/count budget on the non-count-only descent; + promote the two concurrency knobs to `env.py` (3.4 + 4.5 + 4.6 + 4.7). +4. **Remaining test debt:** archive/mtime regression test (the strongest possible + pinning-test candidate — it silently truncated downloads to zero bytes), + acceptance coverage for both new endpoints, rollup tests if not done pre-merge + (2.4 + 3.3). +5. **Fern regeneration** (after the route rename) + move `driveFlatFiles.ts` off raw + axios onto the generated client (2.5). +6. **Domain-wide store-error translation** into `MountStorageUnavailable` / + `MountStorageError` in one place (1.5 — pre-existing, not chargeable to this PR). +7. **Product call on the single-batch rollup fallback** (3.3), plus small cosmetics: + `next_cursor` vs the catalogs' `cursor` naming, the `limit=0` sentinel, and the + flat view's `total` being computed before the hidden-path filter so the badge and + the visible list disagree by the dotfile count. +8. **Paged-cursor polish and the `git_aware` contract** (4.10 + 3.2 + the second + open product question): return `next_cursor: None` when the store reports + exhaustion on an exactly-full page; document that a cursor is only valid for the + same `path` and flags (or bind it); and, once the product owner answers whether + `git_aware` promises Git semantics, either fix the cross-file negation and + `with_counts` scope gaps or document and name the flag as best-effort. + +--- + +## Execution handoff (read this first if you are starting implementation cold) + +This section exists so that an orchestrator with no memory of the review sessions can +start implementing from this document alone. Everything above is findings; this is the +operational context that otherwise lived only in the conversation. + +**What the work is.** Implement the pre-merge changes in the two tables under +"Consolidated pre-merge changes": must-fix items 1, 2, 3, 11; strongly recommended +items 4-10, 12, 13. Each table row names the change, the exact files and line +anchors, and the finding with the full analysis. Item ordering that matters: items 10 +and 11 both modify `validate_file_path` and pull in opposite directions (10 tightens +new archive inputs, 11 relaxes the segment allowlist), so one subagent should own +both together. Item 4 (route rename) must land before item "Fern regeneration" +(follow-up ticket 5) and before the frontend caller hardens. Item 1's fix and item +3's tests belong to one subagent (the test pins the fix). The follow-up tickets are +NOT part of this work; file them as GitHub issues on Agenta-AI/agenta (the team uses +`gh`, not Linear) unless told otherwise. + +**Decisions already made by the product owner (Mahmoud), 2026-07-20:** + +- Very large files for "download all" are out of scope. Do not build streaming + object-body reads or byte-based prefetch bounds into the pre-merge work; ticket 3 + covers them later. Do soften the `>4 GB` docstring claim when convenient. +- The `git_aware` semantics question (open question 2) is still unanswered. It + blocks nothing pre-merge; do not wait on it. +- Merging is ALWAYS Mahmoud's action, never an agent's. The end state of this work + is "fixes implemented, tests green, ready for his review," not a merge. + +**Repo and branch facts.** + +- PR: https://github.com/Agenta-AI/agenta/pull/5400, head branch + `fe-refactor/drive-surfaces`, base `main`, reviewed at head SHA + `3ac6af25160a4b2546c682137f9d3ed5865fabe0`. All line anchors in this report are + against that SHA. **The branch has already moved: as of 2026-07-20 afternoon the + remote head is `da040e1f53`, two pushes past the reviewed SHA.** Fetch, diff + `3ac6af25..origin/fe-refactor/drive-surfaces` to see what changed since the + review (some findings may already be addressed — CodeRabbit comments overlap + items 5 and 10), and re-anchor line numbers by symbol name before editing. +- Known incident, resolved: on 2026-07-20 the PR's files (at the reviewed SHA) were + found materialized as uncommitted plain-file copies in the GitButler working + tree, left behind by the pre-review local QA deploy. They contained no original + work and are safe to clear (every byte is re-obtainable from the branch); they + must be cleared before `but pull` and stacking. If a similar pile reappears, + investigate before deleting; the genuinely-local files that must survive any + cleanup are listed in the session's findings (husky hooks, .gitignore font rules, + agent-release-gate resources, agent-workflows docs, marketing files). +- The PR author is Arda Erzin, Agenta's frontend engineer. **The branch is theirs: + never push to it directly** (standing rule: never push others' branches). The + delivery route for fixes (post review comments for the author vs. implement on a + new branch that PRs into `fe-refactor/drive-surfaces`) is Mahmoud's call — confirm + it before creating branches. If implementing: this repo runs GitButler workspace + mode targeting origin/main; a fix branch based on another feature branch does not + fit a GitButler lane cleanly, so expect to work from a plain checkout elsewhere or + get explicit instructions. Do not improvise with raw git in the main working copy. +- The local working tree at `/home/mahmoud/code/agenta` is a GitButler workspace + with UNCOMMITTED local changes that overlap the PR's files (same mounts/Drives + paths). Never read the PR's state from the working tree and never checkout, stash, + or reset there. Read the PR's code with + `git show origin/fe-refactor/drive-surfaces:`; fetch first if stale. +- Backend CI on the PR was green at review time (format, lint, unit, integration, + acceptance); web unit/acceptance checks were failing (not diagnosed here, frontend + scope). CodeRabbit has open PR comments matching items 5 and the `filename` part + of 10 — resolve those threads when the fixes land. + +**Key file map (all under `api/oss/src/` unless noted).** + +- `core/mounts/service.py` (~1345 lines at the reviewed SHA) — everything central: + `validate_file_path` (:109, regex at :43), `_rollup_recent_entries` (:229-327), + `list_files` (:787-1059; shallow branch :845-948, flat/recency :950-1012, browse + :1014-1059), `list_files_page` (:1061-1166, sentinel bug at :1149), + `iter_archive_members` (:1182-1248), gitignore helpers (:638-785). +- `apis/fastapi/mounts/router.py` — `get_mount_files` (:506), `get_mount_files_page` + (:553), `archive_mount_files` (:662), route registrations (:195-261). +- `apis/fastapi/mounts/utils.py` — `stream_mounts_archive` (:72-123). +- `apis/fastapi/mounts/models.py` — `ArchiveMount`/`MountArchiveRequest` (:39-53), + response models (:70-90). `core/mounts/dtos.py` — core DTOs. +- `core/store/storage.py` — `_client` (:107), `list_objects_page` (:338), + `list_objects_shallow` (:368), `get_object` (:395). +- Tests: `api/oss/tests/pytest/unit/test_mounts_file_ops.py` (473 lines on the + branch; in-memory fake store at the top — extend it with `list_objects_page` for + item 3). Acceptance suite precedent: + `api/oss/tests/pytest/acceptance/mounts/test_mounts_basics.py`. + +**Verification commands.** + +- Unit tests: from `api/`, `uv run pytest oss/tests/pytest/unit/test_mounts_file_ops.py -x -q` + (full suite: `cd api && py-run-tests`). +- Lint/format before committing: `ruff format` then `ruff check --fix` in `api/`. +- The sentinel-bug regression test needs a filename with a code point ≥ U+10000 + (e.g. `"\U00020000file.txt"`) inside a gitignored directory; assert the pager + terminates and skips it. The fix: replace the U+FFFF append at `service.py:1149` + with the prefix-successor (`f"{mount_base}{ignored_dir}" + chr(ord("/") + 1)`) + and add a guard that breaks (or raises) if `start_after` does not strictly + increase between iterations. +- Live QA, if wanted, uses the local docker-compose stack (see AGENTS.md "Local dev + loop"); the drive UI lives in the session Files drawer of the playground. + +**Provenance.** Review produced 2026-07-20 by Claude (Fable 5): four independent +Fable 5 lens agents, a synthesis pass, then a cross-review against Codex's +independent report with every adopted claim re-verified against the branch +(including executing the path regex). The lens agents' raw reports lived in a +session scratch directory and are gone after the session; this document is +self-contained and supersedes them. diff --git a/docs/design/agent-workflows/scratch/pr-5400-fix-implementation-brief.md b/docs/design/agent-workflows/scratch/pr-5400-fix-implementation-brief.md new file mode 100644 index 0000000000..a27c9b315c --- /dev/null +++ b/docs/design/agent-workflows/scratch/pr-5400-fix-implementation-brief.md @@ -0,0 +1,514 @@ +# PR #5400 fix implementation brief — verified against the current head + +> Prepared 2026-07-20 by Claude (Fable 5). This brief re-verifies the review report at +> `pr-5400-drive-mounts-review-report.md` (same folder) against the CURRENT head of the +> PR branch and is the working document for implementation agents. The review was done +> at commit `3ac6af25160a4b2546c682137f9d3ed5865fabe0`; the branch has since gained two +> commits. **Current head: `da040e1f53fae8e31d1ff1a5ae249b917fc1362a`** (confirmed both +> via `git ls-remote`-equivalent fetch and via `gh pr view 5400`; the reviewed SHA is a +> confirmed ancestor). All line numbers in this brief are re-anchored to `da040e1f53`, +> read via `git show origin/fe-refactor/drive-surfaces:` — never the working tree. +> Where this brief and the review report disagree on a line number, this brief wins; +> for the full analysis behind each item, the review report's findings sections win. +> +> Standing constraints (from the review's Execution handoff, still in force): the branch +> belongs to Arda Erzin — never push to it directly; the local working copy is a +> GitButler workspace with uncommitted overlapping changes — never read PR state from +> the working tree, never checkout/stash/reset there; merging is always Mahmoud's +> action; very large files are out of scope for "download all"; the `git_aware` +> semantics product question is still open and blocks nothing here. + +## What changed since the review + +Two commits landed between the reviewed SHA and the current head: + +1. **`84adb07836` — "fix(drive): address review — validate mount_id, sanitize download + headers, surface flat-page errors."** This commit responds directly to the four open + CodeRabbit review threads (all posted 2026-07-20T08:30): + - CodeRabbit on `models.py:45` (type `mount_id` as UUID) → fixed: + `ArchiveMount.mount_id` is now `UUID` and the manual `UUID(...)` cast in + `router.py` is gone. This fully resolves **review item 5**. + - CodeRabbit on `utils.py:138` (sanitize the archive `filename` in + `Content-Disposition`) → addressed with a new `_content_disposition_attachment` + helper in `utils.py`, used by both the single-file download and the archive + stream. This resolves the header-injection half of **item 10's** filename concern + but NOT the non-latin-1 500 (verified below, see item 10 and "New concerns"). + - CodeRabbit on `service.py:903` (loop variable `sub` shadows `re.sub`, F402) → + fixed by renaming to `sub_key`. Cosmetic; no review item. + - CodeRabbit on `driveFlatFiles.ts:172` (mid-list page-load errors silent) → + addressed on the frontend (`DriveExplorer.tsx`, `chatFileRefs.tsx`, + `driveFlatFiles.ts`). Frontend scope; no review item. +2. **`da040e1f53` — "refactor(drive): move flat-file paging onto the Fern client + + useInfiniteQuery."** Frontend-only. Rewrites + `web/oss/src/components/Drives/driveFlatFiles.ts` to call the paged endpoint + through a new `queryMountFilePage` wrapper in + `web/packages/agenta-entities/src/session/api/api.ts`, backed by hand-added methods + (`getMountFilesPage`, `archiveMountFiles`) and types in the **generated** client + directory `web/packages/agenta-api-client/src/generated/`. This partially addresses + follow-up ticket 5 (move `driveFlatFiles` off raw axios), but deepens the + generated-directory drift: the "generated" client now contains even more hand-written + code that no Fern regeneration produced, and the new `archiveMountFiles` client + method is dead code (nothing calls it — `driveMedia.ts` still uses raw + `fetch`/axios for the archive). + +The only backend (`api/`) changes since the review are the four files in `84adb07836`: +`models.py` (UUID type), `router.py` (drop the cast), `utils.py` (the +Content-Disposition helper), and `service.py` (the `sub` → `sub_key` rename plus its +reflow). Nothing else in the review's backend findings was touched. The API-side diff +since the review is small enough to read in one sitting: +`git diff 3ac6af25..origin/fe-refactor/drive-surfaces -- api/`. + +Net effect on the review's 13 pre-merge items: **item 5 is ALREADY FIXED, item 10 is +PARTIALLY FIXED, the other 11 items are all STILL NEEDED.** Because the backend diff is +tiny, most anchors moved by only 0-3 lines; every item below quotes the current code +with current line numbers. + +## Per-item status + +All paths are under `api/oss/src/` unless noted. "service.py" = +`api/oss/src/core/mounts/service.py` (1347 lines at head), "router.py" = +`api/oss/src/apis/fastapi/mounts/router.py` (695 lines), "utils.py" = +`api/oss/src/apis/fastapi/mounts/utils.py` (169 lines), "models.py" = +`api/oss/src/apis/fastapi/mounts/models.py` (123 lines). + +### Item 1 — U+FFFF cursor-jump infinite loop — STILL NEEDED (must fix) + +The sentinel append is still present, now at **service.py:1152** (was :1149). Current +code, `list_files_page`'s paging loop (service.py:1126-1167): + +```python +1126 while len(kept) < limit: +1127 objs, has_more = await self.mounts_store.list_objects_page( +1128 bucket=bucket, +1129 prefix=list_prefix, +1130 start_after=start_after, +1131 max_keys=store_page, +1132 ) +1133 if not objs: +1134 break +1135 jumped = False +... +1147 if specs and not include_gitignored: +1148 ignored_dir = _first_ignored_ancestor_dir(rel, specs) +1149 if ignored_dir is not None: +1150 # Skip the whole ignored subtree in one hop: U+FFFF sorts after every +1151 # real key under `dir/`, so the next page resumes just past it. +1152 start_after = f"{mount_base}{ignored_dir}/" + "￿" +1153 jumped = True +1154 break +``` + +The loop continues at service.py:1157-1167 (`kept` append, `next_cursor` on a full +page, `if jumped: continue`, `if not has_more: break`). The bug is unchanged from the +review: U+FFFF encodes as `EF BF BF` in UTF-8, every astral code point encodes from +`F0` and therefore sorts after the sentinel, so a key like +`node_modules/𠀀file.txt` inside a gitignored directory is re-listed forever — the +loop resets `start_after` backwards to the same sentinel on every iteration with no +exit condition. + +**Change to make:** replace line 1152 with the prefix-successor jump — increment the +last byte of the prefix instead of appending a sentinel: +`start_after = f"{mount_base}{ignored_dir}" + chr(ord("/") + 1)` (i.e. `dir/` → +`dir0`), which sorts after every key under `dir/` in byte order with no exceptions. +Additionally add a forward-progress guard to the `while` loop: remember the previous +`start_after` at the top of each iteration and break (or raise) if it did not strictly +increase. Update the comment at 1150-1151 to state the byte-order rationale. Item 3's +regression test pins this fix; implement them together. + +### Item 2 — archive validates after the 200 headers — STILL NEEDED (must fix) + +Unchanged in structure. The handler (router.py:662-677) returns the +`StreamingResponse` immediately; every mount resolution happens lazily inside the +generator after headers are sent, so `MountNotFound` / `MountStorageUnavailable` never +reach `@handle_mount_exceptions()` and a bad request yields HTTP 200 with a corrupt or +zero-byte zip. Current handler: + +```python +662 @intercept_exceptions() +663 @handle_mount_exceptions() +664 async def archive_mount_files( +665 self, +666 request: Request, +667 *, +668 archive_request: MountArchiveRequest, +669 ): +670 await self._check(request, Permission.VIEW_MOUNTS) +671 +672 return await stream_mounts_archive( +673 mounts_service=self.mounts_service, +674 project_id=UUID(request.state.project_id), +675 mounts=[(m.mount_id, m.prefix, m.path) for m in archive_request.mounts], +676 filename=archive_request.filename, +677 ) +``` + +`stream_mounts_archive` is now at **utils.py:87-141** (shifted down ~15 lines by the +new `_content_disposition_attachment` helper); its `members()` generator defers all +work to `mounts_service.iter_archive_members`. Inside the service, +`iter_archive_members` (service.py:1184-1251) already separates the phases: it first +builds the full ordered work list — the loop at service.py:1202 (`for mount_id, +prefix, source_path in mounts:`) calls `self._resolve_mount(...)` at :1203 and lists +each mount's objects — and only then starts the bounded-concurrency byte reads. + +**Change to make:** hoist the resolution/work-list phase so it runs eagerly, before +the `StreamingResponse` is constructed. The clean seam: split `iter_archive_members` +into a `build_archive_work_list(project_id, mounts) -> List[...]` coroutine (the code +currently at service.py:1200-1224) and an `iter_archive_members(work) -> +AsyncIterator` that does only the prefetch/yield phase (:1226-1251). The router (or +`stream_mounts_archive`) awaits the work-list builder first — so `MountNotFound` +propagates through the decorators as a real 404 and storage-unavailable as 503 — and +only then constructs the `StreamingResponse` over the byte-read iterator. Mid-stream +read errors remain unavoidable and stay as they are. If item 8's companion refinement +is done in the same pass, the work-list entries become the named +`MountArchiveSource` DTO instead of anonymous tuples. + +### Item 3 — fake-backed unit tests for `list_files_page` — STILL NEEDED (must fix) + +The test file `api/oss/tests/pytest/unit/test_mounts_file_ops.py` is byte-identical to +the reviewed SHA (empty diff, still 473 lines). The in-memory `FakeMountStorage` +(defined at line 70) still implements only `list_objects_v2` (:75), +`list_objects_shallow` (:83), `get_object` (:105), `put_object` (:111), `delete_keys` +(:115), and `delete_prefix` (:124) — **it does not implement `list_objects_page`**, so +`list_files_page` still cannot be exercised at all. No new tests were added anywhere +under `api/oss/tests/` since the review. + +**Change to make:** implement `list_objects_page(*, bucket, prefix, start_after, +max_keys) -> Tuple[List[StoreObject], bool]` on `FakeMountStorage` with byte-order +(UTF-8-encoded key) sorting to match S3 semantics — the sort order is the whole point +of the item-1 bug, so sort by `key.encode("utf-8")`, not by `str`. Then add tests: (a) +plain paging across page boundaries (write more files than one `limit`, walk +`next_cursor` to exhaustion, assert the union is complete, ordered, and non-repeating); +(b) the git-aware ignored-directory jump with an astral-plane filename — a gitignored +directory containing e.g. `"\U00020000file.txt"` — asserting the pager terminates, +skips the whole ignored subtree, and returns the surrounding keys. Test (b) must fail +against the current sentinel code and pass with item 1's fix. The review's +verification command: from `api/`, +`uv run pytest oss/tests/pytest/unit/test_mounts_file_ops.py -x -q`. + +### Item 4 — rename `POST /files/archive` — STILL NEEDED (recommended) + +The route is unchanged at router.py:197-204, still colliding with the soft-delete +`archive_mount` registered directly below (:205-213) and still carrying the +ordering-hack comment: + +```python +195 # Registered BEFORE "/{mount_id}/archive" so `POST /files/archive` (download-all zip) isn't +196 # captured as archiving a mount literally named "files". +197 self.router.add_api_route( +198 "/files/archive", +199 self.archive_mount_files, +200 methods=["POST"], +201 operation_id="archive_mount_files", +``` + +**Change to make:** rename the path to a non-lifecycle verb — the review suggests +`/files/export` or `/files/zip` (pick one; `/files/export` reads better next to the +existing `/files/download`). Rename the handler and `operation_id` to match (e.g. +`export_mount_files`), and update or drop the ordering comment (the registration-order +hack is still needed only if the new name can be captured by `/{mount_id}/archive`, +which it cannot — but it CAN still be captured by other `/{mount_id}/...` routes, so +keep it registered early and reword the comment). **The rename now has THREE frontend +touchpoints, one more than at review time** (commit `da040e1f53` added the third): + +- `web/oss/src/components/Drives/driveMedia.ts:232` — the streaming download path: + ``const url = `${getAgentaApiUrl()}/mounts/files/archive?project_id=${encodeURIComponent(projectId)}` `` +- `web/oss/src/components/Drives/driveMedia.ts:260` — the buffered fallback: + ``await axios.post(`${getAgentaApiUrl()}/mounts/files/archive`, payload, ...)`` +- `web/packages/agenta-api-client/src/generated/api/resources/mounts/client/Client.ts` + — the hand-added `archiveMountFiles` method builds `"mounts/files/archive"` (around + line 559) and names the route in its error string (around :594). This method is + currently dead code, but it must not be left pointing at a route that no longer + exists; update its URL (and consider renaming the method to match the new + `operation_id`, since a future real Fern regeneration will do exactly that). + +All three must change in the same PR as the route rename so the branch stays +self-consistent. `web/packages/agenta-entities/src/session/api/api.ts` does NOT call +the archive route (verified); no change needed there. + +### Item 5 — `ArchiveMount.mount_id` as UUID — ALREADY FIXED + +Commit `84adb07836` made exactly the requested change. Current code, models.py:44: +`mount_id: UUID` (with the `from uuid import UUID` import added), and router.py:675 +now passes `m.mount_id` with no manual cast. A malformed id now 422s via Pydantic. +Nothing to do. When the fixes land, resolve the corresponding CodeRabbit thread on +`models.py:45` if it has not auto-resolved. + +### Item 6 — constrain `depth` — STILL NEEDED (recommended) + +Unchanged. The router advertises `ge=1` at **router.py:515** +(`depth: Optional[int] = Query(default=None, ge=1)`) but the service only implements +depth 1: the shallow branch fires only on `if depth == 1 and order is None and limit +is None:` at **service.py:845**, so `depth=2` silently falls through to the full-tree +browse listing — the most expensive branch. + +**Change to make:** change the router constraint to `ge=1, le=1` (or, equivalently, +reject any value other than 1 with a 422) so unsupported depths fail loudly instead of +silently selecting the full enumeration. One-line change at router.py:515 plus a word +in the handler docstring. + +### Item 7 — make `order` a Literal/enum — STILL NEEDED (recommended) + +Unchanged. **router.py:513**: `order: Optional[str] = Query(default=None)` accepts any +string; the service dispatch at **service.py:996-1014** compares against `"recent"` +(:996), `"name"` (:1008), `"path"` (:1010), and any other value falls through all +three and returns the flat view unsorted with no error (the final `return` at :1014). + +**Change to make:** type the router parameter as +`Optional[Literal["recent", "name", "path"]]` so anything else 422s at the boundary. +The service signature can adopt the same Literal for documentation value, but the +router constraint is the load-bearing change. + +### Item 8 — named DTO for `list_files_page`'s return — STILL NEEDED (recommended) + +Unchanged. **service.py:1063-1073**: `list_files_page` is declared +`-> Tuple[List[MountFile], Optional[str]]` (the annotation on :1073), the exact bare +tuple shape `api/AGENTS.md` names as an anti-pattern. It is unpacked at +**router.py:569**: + +```python +569 files, next_cursor = await self.mounts_service.list_files_page( +... +578 return MountFilePageResponse( +579 count=len(files), +580 files=files, +581 next_cursor=next_cursor, +582 ) +``` + +**Change to make:** add a `MountFilePage` DTO (fields `files: List[MountFile]`, +`next_cursor: Optional[str]`) in `core/mounts/dtos.py` next to its siblings, return it +from the service, and read `.files` / `.next_cursor` in the router. Per the Codex +refinement adopted in the review, do the archive path's tuples in the same pass: the +`(UUID, str, str)` triples that travel router → `stream_mounts_archive` → +`iter_archive_members` (router.py:675, utils.py:92 `mounts: List[Tuple[UUID, str, +str]]`, service.py:1188) become a named +`MountArchiveSource(mount_id, archive_prefix, source_path)` DTO. Note the mild overlap +with item 1 (same function body) and item 2 (same archive signatures): implement +after those or in the same PR, not in a parallel one. + +### Item 9 — unit tests for `_rollup_recent_entries` — STILL NEEDED (recommended) + +Unchanged. `_rollup_recent_entries` sits at **service.py:229-329** (was :229-327; +shifted 2 lines by the `sub_key` reflow), still a pure +`List[MountFile] → List[MountFile]` function with zero tests. The behaviors to pin are +listed in the review's Finding 3.3: clone-then-edit collapses to the top directory; an +old-plus-fresh directory does not collapse; an untimed leaf blocks collapse; a +single-batch mount produces no rollup (pin the current behavior — the fallback is a +product decision deferred to follow-up ticket 7); shallow-to-deep resolution picks +`repo/` over `repo/web/`; and the file-and-directory-same-key edge (the file row is +silently dropped, currently at service.py:322). Pure-function tests, no fake store +needed; add a new test class in `test_mounts_file_ops.py`. + +### Item 10 — validate the archive body's path-shaped inputs — PARTIALLY FIXED + +**What `84adb07836` fixed:** the `filename` header-injection vector. Both +`download_mount_file` (utils.py:83) and `stream_mounts_archive` (utils.py:137) now +route through the new helper at **utils.py:20-31**: + +```python +20 def _content_disposition_attachment(filename: str) -> str: +... +28 safe = ( +29 "".join(c for c in filename if c.isprintable() and c not in '"\\') or "download" +30 ) +31 return f"attachment; filename=\"{safe}\"; filename*=UTF-8''{quote(filename)}" +``` + +Quote and control-character breakout is fixed (`isprintable()` drops controls, the +filter drops `"` and `\`). + +**What remains — three gaps:** + +1. **The non-latin-1 500 is NOT fixed.** `str.isprintable()` keeps every printable + Unicode character, so a filename like `中文 report.zip` survives into the plain + `filename="..."` parameter; Starlette encodes response headers as latin-1 and + raises `UnicodeEncodeError`, which is still a 500 (verified by executing the + filter and the latin-1 encode). The docstring's "ASCII-ish fallback" claim is + wrong. Fix: tighten the fallback filter to + `c.isascii() and c.isprintable() and c not in '"\\'` — the `filename*` parameter + already carries the exact UTF-8 name for modern clients. Also pass `safe=""` to + `quote(filename)` so `/` gets percent-encoded in `filename*` (cosmetic but + correct). Add a unit test asserting the returned header value encodes to latin-1 + for CJK, emoji, quote, and control-character inputs. +2. **`ArchiveMount.path` still skips `validate_file_path`.** models.py:44-46 has no + validation and neither the router (:662-677) nor `iter_archive_members` validates + `path`/`prefix`. Fix: in the handler (or a Pydantic validator on `ArchiveMount`), + call `validate_file_path` on each non-empty `path` and `prefix` — using the + item-11 relaxed validator, since these are the same real-world folder names. +3. **Zip entry names are still built from raw input.** service.py:1205-1206 only + strips slashes (`pfx = prefix.strip("/")`, `src = source_path.strip("/")`) and + :1223 builds `zip_path = f"{pfx}/{rel}" if pfx else rel`, where `rel` comes from + raw store keys that signed-credential writers control — so `..` segments can + appear in zip entry names ("zip-slip" for whoever extracts). Fix: when building + `zip_path`, drop or reject `..` and empty segments from both `pfx` and `rel` + (a small `_safe_zip_path` helper; skipping the offending member with a log is + acceptable since the requester mostly harms their own download). + +Items 10 and 11 both touch `validate_file_path` from opposite directions (10 applies +it to new inputs, 11 relaxes what it accepts); one agent owns both together. + +### Item 11 — relax `validate_file_path`'s segment allowlist — STILL NEEDED (must fix) + +Unchanged. Current code: + +```python +42 # Folder/file path segments: word chars, dots, spaces, hyphens — no path traversal. +43 _SEGMENT_RE = r"[\w. -]+" +... +109 def validate_file_path(path: str) -> None: +... +116 if path.startswith("/"): +117 raise MountPathInvalid("File path must not be absolute.") +118 if not path.strip("/"): +119 raise MountPathInvalid("File path must not be empty.") +120 for segment in path.split("/"): +121 if not segment: +122 continue +123 if segment == ".." or not fullmatch(_SEGMENT_RE, segment): +124 raise MountPathInvalid() +``` + +The allowlist still rejects `( ) [ ] @ + , # ~` and quotes, so real folders agents +create — `app/(auth)/[slug]`, `@scope/pkg`, `c++.md` — appear in listings but 422 on +every expand, page, read, or download. The PR's lazy-browse flow round-trips `path` on +every folder expansion, which is what promotes this latent restriction into a +headline-flow bug. + +**Change to make:** replace the character allowlist with a denylist of the genuinely +unsafe: keep the absolute-path and empty checks, reject a segment that is `.` or `..`, +and reject NUL and control characters (`ord(c) < 0x20` or `c == "\x7f"`) anywhere in +the path; drop the `_SEGMENT_RE` allowlist (or redefine it as the control-char +denylist). Callers are unchanged — `validate_file_path` is called at service.py:828 +(`list_files`), :1091 (`list_files_page`), :1178, :1275, :1293, :1314 (read, write, +folder, delete) and via the routers. Extend `TestFilePathValidation` (test file :34-67) +with realistic accepts (`app/(auth)/[slug]/page.tsx`, `@scope/pkg/index.js`, `c++.md`, +`a,b.txt`, `notes#1.txt`, `~backup`) and keeps-rejecting cases (`..` middle segment, +absolute, embedded NUL/control chars, `.` segment). Design together with item 10's +tightening (opposite directions on the same function). + +### Item 12 — one meaning for `total` — STILL NEEDED (recommended) + +Unchanged; anchors shifted slightly. The documented meaning lives at +**models.py:76-83** (`MountFileListResponse`, `total` at :80: "Full file count +matching the request before any limit"). The three computed meanings: shallow mode +returns immediate files-plus-folders (`return MountFileList(files=shallow, +total=len(shallow))`, **service.py:950**); recency/flat mode returns the pre-limit +leaf-file count (`total = len(files)` at **:993**, returned at :995/:1002/:1007/:1014); +browse mode returns recursive files-plus-synthesized-folders +(`return MountFileList(files=browse_files, total=len(browse_files))`, **:1061**). + +**Change to make:** pick one contract. The cheapest honest fix is documentation-plus- +naming: reword the `total` docstring at models.py:78-80 to state the per-mode meaning +explicitly (files-only in flat/recency mode; entries-including-folders in +shallow/browse modes). The stronger fix is per-view fields, but that changes the wire +contract the frontend already consumes — do not do that without checking the three +frontend consumers of `total`. Either way this is a small, self-contained change; the +review requires only that the field stop having three undocumented meanings. + +### Item 13 — cap the plain count-only path — STILL NEEDED (recommended) + +Unchanged. Current code, service.py:953-970: + +```python +953 if order is not None or limit is not None: +... +956 count_only = limit == 0 and order is None +957 cap = _COUNT_CAP if count_only else None +958 if git_aware: +... +961 store_files, specs, truncated = await self._list_pruned_files( +962 base_prefix=list_prefix, mount_base=mount_base, cap=cap +963 ) +964 else: +965 # RAW: every object under the prefix, no pruning (matches the plain-endpoint contract). +966 objects = await self.mounts_store.list_objects_v2( +967 bucket=self._bucket(), prefix=list_prefix +968 ) +969 store_files = [o for o in objects if not o.key.endswith("/")] +970 specs: List[Tuple[str, "pathspec.PathSpec"]] = [] +``` + +The cap (`_COUNT_CAP = 20000`, service.py:154) is only passed to the git-aware pruned +descent; the plain branch runs a full uncapped `list_objects_v2` enumeration and +hardcodes `truncated = False` (the line after :970). A raw count-only request on a +huge tree enumerates everything. + +**Change to make:** when `count_only` and not `git_aware`, bound the enumeration to +`_COUNT_CAP` — either via a capped paged walk using `list_objects_page` (count keys +until the cap, set `truncated=True` if more remain) or by truncating after +`list_objects_v2` if the store API cannot stop early (weaker but still honest: +`truncated = len(store_files) > _COUNT_CAP` then slice). Prefer the paged walk — the +whole point is not enumerating. Set `total_capped` accordingly so the UI's "N+" +affordance works in both modes. Add a fake-backed test (the fake gains +`list_objects_page` in item 3 anyway). + +## New concerns from the new commits + +1. **The Content-Disposition fallback is not latin-1-safe** (detailed under item 10). + The new sanitizer in `84adb07836` fixes injection but still 500s on any printable + non-latin-1 filename, which the review had explicitly called out. Fold the + ASCII-only fix into the item 10/11 work unit. +2. **The generated-client drift got deeper.** `da040e1f53` hand-writes ~230 more lines + into `web/packages/agenta-api-client/src/generated/` (a `getMountFilesPage` and an + `archiveMountFiles` method plus five type files). The directory now matches no Fern + regeneration run even more thoroughly, and `archiveMountFiles` is dead code + (nothing calls it; `driveMedia.ts` still uses raw fetch/axios for the archive). + This stays follow-up ticket 5 (one real Fern regeneration after the item-4 rename), + but item 4's PR must at minimum keep the hand-added method's URL consistent. +3. Nothing else: the `sub` → `sub_key` rename is behavior-neutral (verified in the + diff), and the frontend error-surfacing changes are outside backend scope. Only + `84adb07836` touches `api/`; `da040e1f53` is frontend-only. + +## Proposed PR split + +Recommendation: **two stacked PRs on top of `fe-refactor/drive-surfaces`** (per the +product owner's delivery constraint; the branch itself is Arda's and is never pushed +directly). Two rather than one because the work divides into an uncontroversial +correctness tier that should be reviewable and mergeable fast, and a contract tier +that changes wire shapes and touches the frontend — different reviewers care about +each, and the correctness tier should not wait on contract discussion. One PR would +also work if review bandwidth is scarce (total diff is moderate), but the two-story +split keeps each review coherent. + +**PR 1 — "fix(api): mounts file-ops correctness — cursor jump, eager archive +resolution, count cap, tests"** (base: `fe-refactor/drive-surfaces`; `api/` only) + +- Item 1: prefix-successor cursor jump + forward-progress guard + (service.py:1126-1167). +- Item 3: `list_objects_page` on the test fake (byte-order sorting) + paging tests + + the astral-filename regression test that pins item 1. +- Item 2: eager mount resolution / work-list build before the `StreamingResponse` + (router.py:662-677, utils.py:87-141, service.py:1184-1251). +- Item 13: `_COUNT_CAP` on the plain count-only path (service.py:953-970) + test. +- Item 9: `_rollup_recent_entries` pinning tests (service.py:229-329). + +One story: the PR's new listing, counting, and archive paths now terminate, fail +loudly, and are tested. No wire-contract change (the eager-resolution change only +turns silent corrupt-zip 200s into the 404/503 the decorators already advertise). + +**PR 2 — "fix(api): mounts file-ops contract — path validation, route rename, typed +params and DTOs"** (base: PR 1's branch; `api/` + `web/`) + +- Items 11 + 10 (one work unit, opposite directions on `validate_file_path`): relax + the segment allowlist to a control-char/traversal denylist + tests with real repo + names; apply validation to `ArchiveMount.path`/`prefix`; sanitize zip entry names; + make the Content-Disposition fallback ASCII-only (the new-commit gap). +- Item 4: rename `POST /files/archive` → `/files/export` including all three frontend + touchpoints (`driveMedia.ts:232`, `driveMedia.ts:260`, the hand-added + `archiveMountFiles` in the generated `Client.ts`). +- Item 6: `depth` constrained to `le=1` (router.py:515). +- Item 7: `order` as a `Literal["recent", "name", "path"]` (router.py:513). +- Item 8: `MountFilePage` DTO for `list_files_page` + `MountArchiveSource` for the + archive tuples (builds on PR 1's item-2 seam, which is why this PR stacks on PR 1). +- Item 12: give `total` one documented meaning (models.py:76-83). + +One story: tighten the file-ops wire contract — reject what the API cannot honor, +accept the paths real repos actually contain, and name the shapes — before the +frontend hardens against the current ambiguities. Item 5 is already fixed on the +branch and appears in neither PR. + +Sequencing note inside PR 2: land item 11's relaxed validator before wiring item 10's +new validation call sites, so the archive inputs are validated against the corrected +rules from the start. After both PRs, resolve the open CodeRabbit threads that the +fixes complete (the `utils.py` filename thread is only half-answered until the +ASCII-only fallback lands). diff --git a/docs/design/agent-workflows/scratch/research-client-tools-and-concurrent-hitl.md b/docs/design/agent-workflows/scratch/research-client-tools-and-concurrent-hitl.md new file mode 100644 index 0000000000..edce1c0d29 --- /dev/null +++ b/docs/design/agent-workflows/scratch/research-client-tools-and-concurrent-hitl.md @@ -0,0 +1,259 @@ +# Research: client-tool delivery per harness/sandbox, and concurrent HITL + +Read-only research against CURRENT `gitbutler/workspace` HEAD (post sandbox_agent decomposition, +`services/runner/src/engines/sandbox_agent/`). Every claim cited `file:line`. Two sections, one +per question, each closing with hard constraints any design must respect. + +--- + +## QUESTION 1 — How client tools reach each harness/sandbox combination, and what breaks for Claude on Daytona + +### Where tool specs come from (run-plan.ts) + +`buildRunPlan` reads `request.customTools` into `toolSpecs` +(`run-plan.ts:363`) and computes the executable subset +`executableToolSpecsForRun = executableToolSpecs(toolSpecs)` (`run-plan.ts:364`). `executableToolSpecs` +filters OUT client-kind tools; the plan carries both lists separately (`run-plan.ts:149-150`, +returned at `run-plan.ts:544-545`). The relay is armed whenever any tool exists: +`useToolRelay = toolSpecs.length > 0` (`run-plan.ts:550`). + +The shared pause seam for a `client` tool is `buildClientToolRelay` (`client-tools.ts:205-268`). +Its docstring names the two delivery channels that both consume it: Pi via the file relay +(`tools/relay.ts` → `startToolRelay`), Claude-local via the loopback MCP server +(`tools/tool-mcp-http.ts`) (`client-tools.ts:6-15`). + +### The four combinations + +**Pi local — file relay.** Pi delivers tools through its bundled EXTENSION, never over ACP MCP; +`buildSessionMcpServers` returns `[]` for Pi (`mcp.ts:305-313`). Tools flow through the runner +file relay (`run-turn.ts:398-412`, host = `localRelayHost`). A `client` tool parks inside the relay: +`executeRelayedTool` sees `spec.kind === "client"`, calls `clientToolRelay.onClientTool`, and on a +`pendingApproval` decision calls `clientToolRelay.onPause` and returns `PAUSED` +(`relay.ts:326-345`). So **client tools park on Pi-local.** + +**Pi Daytona — file relay over the sandbox FS.** Same extension + file relay, but the relay host is +`sandboxRelayHost` (`run-turn.ts:400-403`), which polls the sandbox filesystem. The client-tool park +path is the same relay code (`relay.ts:326-345`). So **client tools park on Pi-Daytona too** (the +relay round-trips the sandbox FS; the ClientToolRelay lives in runner memory and the relay loop that +services request files runs on the runner). + +**Claude local — loopback HTTP MCP.** Tools ride the internal loopback (`127.0.0.1`) HTTP MCP server +`buildToolMcpServers` (`mcp.ts:327-332`). The `clientToolRelay` is passed into +`buildSessionMcpServers` ONLY on the non-Daytona branch (`mcp.ts:251-252`, consumed at +`mcp.ts:327-332`), so the `tools/call` handler in `tool-mcp-http.ts` pauses for a client tool through +the same seam. So **client tools round-trip on Claude-local.** + +**Claude Daytona — in-sandbox stdio MCP shim (commit afb00d4218).** The loopback URL is unreachable +from inside the sandbox (its `127.0.0.1` is the sandbox's), so the channel becomes the uploaded stdio +shim: `buildInternalToolMcpEntry` advertises a TYPELESS `{name:"agenta-tools", command:"node", +args:[bundle], env}` ACP entry (`mcp.ts:109-134`) that the Claude ACP adapter maps to a Claude SDK +stdio MCP server. It is built ONLY when `internalToolMcp && executableToolSpecs(toolSpecs).length > 0` +(`mcp.ts:333-337`). The shim (`tool-mcp-stdio.ts`) serves `tools/list` from the uploaded public-specs +file (`tool-mcp-stdio.ts:193-208`) and on `tools/call` writes a relay request file via `relayToolCall` +and BLOCKS awaiting the runner's response (`tool-mcp-stdio.ts:210-254`, the awaited call at +`tool-mcp-stdio.ts:226-232`). + +### The precise mechanical reason client tools cannot round-trip on Claude-Daytona + +Three independent mechanical blocks, all in current code: + +1. **Client specs are never advertised to the model.** The uploaded specs file is built from the + executable subset only, and the shim entry is constructed only when + `executableToolSpecs(toolSpecs).length > 0` (`mcp.ts:333`). `run-plan.ts` omits client tools from + the Daytona shim path deliberately (`run-plan.ts:425-442`, comment at `run-plan.ts:73-90`). So + Claude-in-Daytona never sees a client tool in `tools/list` and cannot call one. + +2. **The shim's `tools/call` is a synchronous request/response with no "park" outcome.** It maps a + call to exactly one of: a text result, or an `isError` result (`tool-mcp-stdio.ts:233-253`). There + is no frame for "pause this turn, resume next turn." It awaits a `.res.json` bounded only by the + per-tool `spec.timeoutMs` (`tool-mcp-stdio.ts:230`). + +3. **The only shim↔runner channel is the FILE RELAY**, a one-request/one-response file exchange + (`.req.json` → `.res.json`) whose env is just `relayDir` + `specsPath` + (`mcp.ts:113-116`, `tool-mcp-stdio.ts:52,226`). The shim holds NO `ClientToolRelay` object — that + lives in runner memory (`client-tools.ts`) and is passed to the MCP server only on the local branch + (`mcp.ts:251-252`). A client-tool round-trip needs a runner→frontend hop mid-call; the file relay's + req/res shape cannot express it. + +**Could the existing runner↔sandbox control connection carry a tool-call-back?** The sandbox-agent +daemon / ACP link (built via `createAcpFetch`, `daemon.ts`) carries the ACP protocol only: session +prompt, `session/update` events, and the permission reverse-RPC +(`onPermissionRequest`/`respondPermission`). That reverse-RPC IS the suspension point for +HARNESS-raised permission gates — this is exactly how Claude approvals park +(`acp-interactions.ts:147-182`, `pauseUserApproval`). But a client tool is NOT an ACP permission gate; +it is an MCP `tools/call` the shim resolves over the file relay, out-of-band from the ACP link. So the +control connection does not today carry an MCP client-tool callback. (Even the runner-side relay loop's +own client-park path, `relay.ts:341-344`, is moot on Daytona because the shim never advertises a client +tool, so no request file is ever written for one.) + +### Where the park/resume machinery hooks in + +- `ParkedApproval` (`runtime-contracts.ts:113-128`) is recorded ONLY for the two ACP permission gate + types (`ParkedApprovalGateType = "claude-acp-permission" | "pi-acp-permission"`, + `acp-interactions.ts:24-26`). It is explicitly NOT recorded for a client-tool MCP pause + (`runtime-contracts.ts:105-111`; `acp-interactions.ts:63-67`). +- `ResumeApprovalInput` (`runtime-contracts.ts:131-139`) answers a parked gate live via + `session.respondPermission` (`run-turn.ts:427-457`) — approvals only. +- Client-tool resume is the COLD-REPLAY path exclusively: the browser output is read back from replayed + history by `extractClientToolOutputs` (`responder.ts:391-409`), and the transcript builder emits a + `client` resume frame (`resumeKindFor` "client", `transcript.ts:195,217-246`; `CLIENT_RESUME_CLOSING`, + `transcript.ts:190-193`). This cold path exists for Claude on Daytona too — but is never reached for a + client tool, because the shim refuses/omits client tools up front (block #1 above). + +### What #5366 does today + +Mapped to `run-plan.ts:425-442`: + +- **Refuse when the tool set is ONLY client tools:** on `!isPi && isRemoteSandbox && isDaytona && + toolSpecs.length > 0`, if `executableToolSpecsForRun.length === 0` it returns + `DAYTONA_CLIENT_ONLY_TOOLS_UNSUPPORTED_MESSAGE` (`run-plan.ts:439-441`; message at + `run-plan.ts:84-90`). Without this the run would proceed, advertise an empty tool set, and return + `ok:true` (the "F1 zero-tools drop"). +- **Drop client tools silently in a MIXED set:** when some executable tools remain, the run proceeds; + the shim's uploaded specs file is `executableToolSpecs(toolSpecs)` only (`mcp.ts:333,344`), so client + tools are simply not advertised (dropped) while executable ones are delivered. The log at + `mcp.ts:341-348` states honestly "N gateway tool(s) advertised", not the client count. +- A non-Daytona remote provider fails closed entirely: `REMOTE_TOOLS_UNSUPPORTED_MESSAGE` + (`run-plan.ts:433-434`, message `run-plan.ts:66-71`). + +### Hard constraints (Section 1) + +- Any Claude-Daytona client-tool design must add a NEW park/resume channel; the file relay's + one-req/one-res shape and the shim's blocking synchronous `tools/call` cannot express a pause + (`tool-mcp-stdio.ts:210-254`). +- Client tools are stripped before the shim's specs upload (`mcp.ts:333`, `run-plan.ts:425-442`); a + design must first make the shim ADVERTISE client tools before the model can ever call one. +- Parked-approval machinery (`ParkedApproval`/`ResumeApprovalInput`/live `respondPermission`) is built + for ACP permission gates only and deliberately excludes client-tool pauses + (`runtime-contracts.ts:105-128`); client-tool resume today is cold-replay only (`responder.ts:391-409`, + `transcript.ts:190-246`). +- The ACP control link carries only harness permission reverse-RPC as a suspension point + (`acp-interactions.ts:147-182`); it is not a general tool-callback channel. +- The `agenta-tools` server name is reserved and coupled to rendered permission rules + (`mcp.ts:71-76`, gate at `run-plan.ts:417-423`) — a new channel must not collide with it. + +--- + +## QUESTION 2 — Two concurrent human-in-the-loop requests + +### Can a turn park with more than one pending interaction? + +Two distinct answers by interaction kind: + +- **APPROVAL gates: NO — one per turn.** Every approval pause is gated by a one-shot latch, + `PendingApprovalLatch.tryAcquire()` (`permission-plan.ts:173-185`: first call `true`, all later calls + `false`). `pauseUserApproval` calls `if (!latch.tryAcquire()) return;` BEFORE emitting the card + (`acp-interactions.ts:164`). A second concurrent approval gate: its `onUserApprovalGate` still fires + and increments the counter (`acp-interactions.ts:154-163`, counter in `run-turn.ts:350-368`), but the + latch blocks the emit — no second `interaction_request` goes out. The sibling gated tool call is then + force-settled as `TOOL_NOT_EXECUTED_PAUSED` (`run-turn.ts:230-236` and the post-drain re-sweep at + `run-turn.ts:482-488`; the pause controller's destroy callback also settles announced-but-unresolved + siblings, `pause.ts:1-8`). + +- **CLIENT tools (relay/MCP seam): YES — multiple park in one turn.** `buildClientToolRelay` does NOT + consult the latch: "Client tools do NOT gate on it — each pending client tool parks its own widget" + (`client-tools.ts:178-181`), and it emits a `client_tool` interaction for EACH pending call + (`client-tools.ts:245-264`). The turn still ends exactly once — `pause.pause()` via `onPause` is + idempotent (`client-tools.ts:248-250,266`; idempotence at `pause.ts:27-42`). (Note: the SEPARATE + ACP-gate client-tool path `pauseClientTool` DOES take the latch, `acp-interactions.ts:190`; but the + primary Pi-relay / Claude-local-MCP client delivery goes through the un-latched `client-tools.ts` + seam.) + +### What the wire emits + +One `tool-approval-request` chunk per `user_approval` `interaction_request` event +(`stream.py:680-684`, in `_interaction_parts`). Because the latch caps approvals at one emit per turn, +the wire carries at most one approval-request per turn. Client-tool events map one part each +(`stream.py:689+`), so several client widgets can stream in one turn. Interaction requests are +projected sequentially as they arrive; there is no batched multi-approval frame. + +### What extractApprovalDecisions accepts + +`extractApprovalDecisions` returns `Map` — a LIST of decisions per cold-replay key +(`responder.ts:358-373`, appended at `responder.ts:368-370`). So a single inbound resume request CAN +carry MANY decisions (across different tool-call keys, plus a FIFO list per identical-args key). +`ConversationDecisions.take` consumes one per key FIFO (`responder.ts:256-263`, `queue.shift()` at +`responder.ts:263`). But because only one gate pauses per turn (the latch), those stored decisions are +consumed one gate at a time as gates RE-RAISE across successive cold-replay turns — the map holds many; +each turn answers one. + +### FE resume predicate with a second unanswered request + +`agentShouldResumeAfterApproval` (`agentApprovalResume.ts:131-165`): it finds the LAST freshly-resolved +parked interaction (`agentApprovalResume.ts:145-149`; comment "a second approval later in the turn is +what should drive the (next) resume", `:143-144`) and then requires +`allSettled = toolParts.every(isSettledToolPart)` (`agentApprovalResume.ts:163-164`). So if a SECOND +interaction is still pending (an unsettled `approval-requested`, or an `input-available` client widget), +`allSettled` is false and the run does NOT auto-resume — it waits until the human answers every pending +interaction. Separately, `isPendingClientToolInteraction` (`agentApprovalResume.ts:93-100`) holds the +message queue while any client widget is still live, so a queued user message cannot inject before the +widget settles. + +### What the SDK ingress does with a second request + +The ingress does not block; it serializes. Each `approval-responded` tool part converts to a +`tool-result` block keyed by `toolCallId` (`messages.py:177-207`, `_approval_response_blocks` +`messages.py:224-227`; decision read at `messages.py:210-222`). Multiple responded parts → multiple +tool-result blocks → all picked up by `extractApprovalDecisions`. So concurrency is resolved by +folding each answer into message history, not by an in-flight second-request handler. + +### Where the "second one gets queued/blocked" behavior lives + +- **Runner (approvals):** the latch (`permission-plan.ts:173-185`, `acp-interactions.ts:164`) — one card + per turn; siblings force-settled (`run-turn.ts:230-236,482-488`). +- **Runner keep-alive:** a multi-gate turn cannot live-park. `env.parkedApproval` records only the FIRST + gate (`run-turn.ts:355-366`), and the dispatch refuses to park when `approvalGateCount > 1`: + `multi-gate-no-park` (`server.ts:428-431`), falling back to the cold decision-map path. A non-parkable + (client-tool) pause also never parks: `non-parkable-gate-no-park` (`server.ts:424-426`). +- **FE:** all-settled gate (`agentApprovalResume.ts:163`) plus the pending-client-tool queue gate + (`agentApprovalResume.ts:93-100`). + +### Pi relay S5.2 gap — can relay tools park at all? + +Documented open issue `open-issues.md:132-169`: the runner relay loop is a synchronous fire-and-forget +poll with NO turn-boundary model, so a resolved code/gateway tool with an `ask` permission cannot emit +an `interaction_request`, end the turn, and resume the same call later. The suspension mechanism the +harness path relies on (the ACP permission request itself blocking on `respondPermission`) does not +exist for a relay execution — "the relay just executes and returns a string inline" +(`open-issues.md:155-158`). Two current-code qualifiers: + +- **Client tools are the exception** — they DO have an explicit park path in the relay + (`relay.ts:326-345`), added after the S5.2 note; so "relay tools cannot park" is specifically about + executable code/gateway `ask` tools, not client tools. +- **Pi APPROVAL asks are routed around the relay:** a Pi gate rides the extension's `ctx.ui.confirm` + onto the ACP permission plane and parks via `pauseUserApproval` with gate type `pi-acp-permission` + (`acp-interactions.ts:289-341`, `relay.ts:314-317`). So Pi custom-tool/builtin approvals park through + the ACP machinery; the narrow residual S5.2 is a relay-executed `ask` that would need to park FROM the + relay loop. + +### Plainly: what is possible / serialized / broken today + +- **Possible now:** exactly one approval card per turn (any harness); multiple client-tool widgets + parked together in one turn (`client-tools.ts:245-264`); many stored decisions carried in one cold + resume, consumed one gate per turn (`responder.ts:358-373`). +- **Serialized:** true-concurrent approval gates. Only the first emits; the rest force-settle + (`TOOL_NOT_EXECUTED_PAUSED`) and the model re-requests them one at a time across cold-replay turns. + Keep-alive explicitly declines to live-park a multi-gate turn (`server.ts:428-431`). +- **Broken / unsupported:** a live (keep-alive) resume of more than one pending approval — no data + structure exists for it (`ParkedApproval` is singular, recorded only for gate #1, + `run-turn.ts:355-366`); and a relay-executed `ask` code/gateway tool cannot park at all (S5.2, + `open-issues.md:132-169`). + +### Hard constraints (Section 2) + +- The approval pause is single-shot per turn by construction (`permission-plan.ts:173-185`, + `acp-interactions.ts:164`); any multi-approval design must replace or relax the latch AND add + force-settle handling for the extra gates (`run-turn.ts:230-236`). +- `ParkedApproval` is singular and records only the first gate (`runtime-contracts.ts:113-128`, + `run-turn.ts:355-366`); live multi-gate resume needs a plural park record and a multi-answer resume + input (today `ResumeApprovalInput` answers one, `run-turn.ts:427-457`). +- The keep-alive dispatch hard-refuses `approvalGateCount > 1` (`server.ts:428-431`); relaxing it + requires answering multiple `permissionId`s on the live session in one resume. +- The FE resume predicate requires ALL parked interactions settled before resuming + (`agentApprovalResume.ts:163`) — a concurrency design must keep this or the queue-gate invariant + (`agentApprovalResume.ts:93-100`) holds forever. +- The decision store already supports many decisions per resume (`responder.ts:358-373`) and client + tools already park in parallel (`client-tools.ts:245-264`); the cold-replay path is the existing + precedent for multiplexing HITL — a live design must not regress it. +- Relay-executed `ask` tools have no park path (S5.2, `open-issues.md:132-169`); any design that routes + executable-tool approvals through the relay must first give the relay a turn-boundary model. diff --git a/docs/design/agent-workflows/scratch/retheme-hex-audit-2026-07-19.md b/docs/design/agent-workflows/scratch/retheme-hex-audit-2026-07-19.md new file mode 100644 index 0000000000..7fc190a42a --- /dev/null +++ b/docs/design/agent-workflows/scratch/retheme-hex-audit-2026-07-19.md @@ -0,0 +1,220 @@ +# Retheme hex audit — navy/zinc to warm brand palette + +Scope: everything under `web/`, grepped case-insensitively for the old palette's raw hex +and `rgba(5, 23, 41 …)` literals, excluding the token source-of-truth files that the +retheme will edit directly (`palette.ts`, `legacy-shim.ts`, `theme-variables.css`, +`tokens/*.json`, `theme/antd-overrides.generated.ts`, `scripts/generate-tailwind-tokens.ts`) +and build output (`node_modules`, `.next`, `dist`, `build`). + +Values searched: `#1c2c3d`, `#051729`, `#f5f7fa`, `#eaeff5`, `#d6dee6`, `#bdc7d1`, +`#758391`, `#586673`, `#394857`, `rgba(5, 23, 41 …)` (both spacing variants), and the +dark-mode/accent values `#141414`, `#424242`, `#303030`, `#d1d151`, `#c2d54a`. + +## 1. Summary + +- **255 matching lines** across **50 files** outside the token files. +- **49 lines need a code edit** (TOKENIZE) before the retheme will reach them. +- **~72 lines are already var-backed** (`var(--ag-c-XXXXX, #xxxxxx)` Tailwind-arbitrary + fallback syntax) or are JSDoc comments — no risk, LEAVE as-is, listed for completeness. +- **132 raw hits** are decorative path strokes/fills inside two near-identical, apparently + **unused** SVG assets (`onboard-page-grids.svg`, OSS + EE) — LEAVE / verify-and-drop. +- **~10 lines** are members of fixed, hash-indexed decorative palettes (avatar + initials-color pairs, template icon colors, an avatar gradient list) where the old-navy + hex is coincidental, not a theme role — LEAVE. +- **1 real bug found in passing**: `web/oss/tailwind.config.ts`'s Tremor "brand" color + block calls antd's `theme.getDesignToken()` with no seed, which returns antd's **stock + default blue** (`colorPrimary: #1677ff`), not Agenta's navy — so today, before any + retheme, Tremor's brand colors already don't match the app. It won't pick up the new + brand color either; needs a code fix, not just a palette edit. See §3. + +| Recommendation | Count (lines) | +|---|---| +| TOKENIZE | 49 | +| LEAVE (already var-backed, fallback hex is inert) | 51 | +| LEAVE (JSDoc comment only) | 20 | +| LEAVE (decorative/hash-indexed palette, coincidental hex) | 6 | +| LEAVE (comment referencing already-tokenized code elsewhere) | 1 | +| LEAVE (orphaned SVG asset, 132 raw occurrences) | 2 files | + +## 2. Per-hit table + +Grouped by file. "Var-backed (LEAVE)" rows use the `var(--ag-c-X, #fallback)` idiom — the +CSS custom property is always defined globally by `theme-variables.css`, so the literal +hex fallback never actually paints; it's inert until/unless someone deletes the var +definition, which is not something this retheme does. + +### SVG assets (decorative, likely orphaned) + +| File | Lines | Excerpt | Kind | Recommendation | +|---|---|---|---|---| +| `web/ee/public/assets/onboard-page-grids.svg` | 7–72 (66 hits: 63×`#051729` stroke/fill, 3×`#758391` fill) | `` | SVG asset, EE | **LEAVE** — grepping `web/oss/src` and `web/ee/src` for `onboard-page-grid` finds **no importer**. This appears to be an orphaned public asset (dead weight, not wired into any page). Recommend confirming with design whether it's still needed; if kept, it must be manually re-exported in the new brand color since a static SVG in `public/` cannot consume CSS vars. | +| `web/oss/public/assets/onboard-page-grids.svg` | 7–72 (66 hits, identical pattern) | same | SVG asset, OSS | **LEAVE** — same as above, byte-identical duplicate of the EE asset. | + +### React components — inline style / JS color constant (needs edit) + +| File:Line | Excerpt | Kind | Recommendation | +|---|---|---|---| +| `web/oss/src/components/DeploymentsDashboard/components/DeploymentCard/index.tsx:30` | `borderColor: selectedEnv === env.name ? "#1C2C3D" : undefined,` | inline style, OSS | **TOKENIZE** → `var(--ag-colorPrimary)` (selected-card emphasis border) | +| `web/oss/src/components/EvalRunDetails/components/views/ConfigurationView/index.tsx:392` | `? "#1c2c3d"` (comparison swatch color, base run) | inline style ternary, OSS | **TOKENIZE** → `var(--ag-colorPrimary)` | +| `web/oss/src/components/Layout/assets/styles.ts:10` | `background: themeMode === "dark" ? "#141414" : "#ffffff",` | react-jss, OSS | **TOKENIZE** → `theme.colorBgContainer` (the JSS `theme` param already carries `colorBorderSecondary`/`colorTextTertiary` used a few lines down — this line should use the same object instead of a manual ternary) | +| `web/oss/src/components/Layout/assets/styles.ts:46` | `backgroundColor: "#1c2c3d",` (top banner) | react-jss, OSS | **TOKENIZE** → `theme.colorPrimary` | +| `web/oss/src/components/Onboarding/OnboardingCard.tsx:763` | `dark:!text-[#141414]` (dark-mode text on the bright primary button) | Tailwind arbitrary, dark variant, OSS | **TOKENIZE, with a gap** → this literal duplicates `componentsDark.Button.primaryColor` (`palette.ts`), which is an antd `components` override and is **not** emitted as a CSS var today. Recommend adding a semantic var (e.g. `--ag-colorPrimaryContrast`) to the generator as part of the retheme rather than hand-picking a new literal here. | +| `web/oss/src/components/pages/agent-home/assets/templates.ts:153` | `color: "#1c2c3d",` (PR reviewer template) | data config, OSS | **LEAVE** — one of ~30 `AGENT_TEMPLATES` entries, each hand-assigned its own decorative accent hex (`#7c2d12`, `#0d9488`, `#be123c`, …), used to color the template's initials avatar. Only 2 of 30 happen to equal the old brand navy; it reads as "one more crayon" in a rainbow list, not a themed role. Safe to leave; will just look slightly duplicative until someone diversifies it. | +| `web/oss/src/components/pages/agent-home/assets/templates.ts:973` | `color: "#1c2c3d",` (Docs Q&A template) | data config, OSS | **LEAVE** — same reasoning as above. | +| `web/oss/src/components/pages/observability/components/ObservabilityHeader/useBatchAddTracesToQueue.tsx:37` | `const PRIMARY_COLOR = "#1c2c3d"` — comment above it literally says *"matches antd's `colorPrimary`"* | JS constant, OSS | **TOKENIZE** → `var(--ag-colorPrimary)`. The author's own comment documents the intent; this is a clean, low-risk fix. | +| `web/oss/src/components/pages/prompts/components/PromptsHouseIcon.tsx:10` | `` | icon component prop, OSS | **TOKENIZE** → `var(--ag-colorIcon)` / `var(--ag-colorIconHover)` per the active/hover branch | +| `web/oss/src/components/pages/prompts/components/SetupWorkflowIcon.tsx:23` | `fill="#1C2C3D"` | inline SVG icon, OSS | **TOKENIZE** → `var(--ag-colorText)` or `var(--ag-colorIcon)` | +| `web/oss/src/components/pages/prompts/hooks/usePromptsColumns.tsx:32` | `` | inline style, OSS | **TOKENIZE** → `var(--ag-colorTextQuaternary)` | +| `web/oss/src/components/pages/prompts/modals/DeleteFolderModal.tsx:32` | `` | inline style, OSS | **TOKENIZE** → `var(--ag-colorTextQuaternary)` | +| `web/oss/src/lib/helpers/colors.ts:44` | `{backgroundColor: "#eaeff5", textColor: "#758391"},` (1 of 12 in `colorPairs`) | data config, OSS | **LEAVE** — a fixed, hash-indexed avatar initials-color palette (12 arbitrary pairs, e.g. `#BAE0FF`/`#1677FF`, `#D9F7BE`/`#389E0D`). This one pair coincidentally equals `zinc-2`/`zinc-6`; it's decorative, not a theme role. Note: this exact 12-pair list is duplicated in `web/packages/agenta-ui/src/components/presentational/avatar/utils.ts` — a dedup opportunity, unrelated to the retheme. | +| `web/oss/src/lib/helpers/colors.ts:4` | `"linear-gradient(to bottom right, #424242, #9F1239, #560BAD)",` (1 of 14 gradients) | data config, OSS | **LEAVE** — decorative avatar-gradient stop; `#424242` here is unrelated to the dark-mode border/zinc-4 role despite the coincidental hex match. | +| `web/packages/agenta-ui/src/components/presentational/avatar/utils.ts:14` | `{backgroundColor: "#eaeff5", textColor: "#758391"},` | data config, package | **LEAVE** — duplicate of the OSS avatar palette above, same reasoning. | +| `web/packages/agenta-ui/src/CellRenderers/EvaluatorMetricBar.tsx:21` | `const FALSE_SEGMENT_COLOR = "#bdc7d1"` | JS constant, package | **TOKENIZE** → `var(--ag-colorTextQuaternary)` | +| `web/packages/agenta-ui/src/CellRenderers/EvaluatorMetricBar.tsx:22` | `const FALSE_LABEL_COLOR = "#586673"` | JS constant, package | **TOKENIZE** → `var(--ag-colorTextTertiary)` | +| `web/packages/agenta-ui/src/CellRenderers/EvaluatorMetricBar.tsx:92` | `const TRACK_BG = "rgba(5, 23, 41, 0.06)"` | JS constant, package | **TOKENIZE** → `var(--ag-colorFillSecondary)` (exact alpha match) | +| `web/packages/agenta-ui/src/type-chip/TypeChip.tsx:184` | `e.currentTarget.style.boxShadow = "0 1px 3px rgba(5, 23, 41, 0.12)"` (hover handler) | inline JS style, package | **TOKENIZE** → `var(--ag-rgba-051729-10)` (closest existing alpha step; exact 0.12 isn't defined) | +| `web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/itemDescriptors.tsx:188` | `color: "#1c2c3d",` | data config, package | **TOKENIZE** → `var(--ag-colorText)` | +| `web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/ProviderCredentialsSection.tsx:141` | `... text-[10px] font-semibold text-[#586673]"` | Tailwind arbitrary, package | **TOKENIZE** → `var(--ag-colorTextTertiary)`. Note the same `className` already correctly uses `var(--ag-colorBorderSecondary)` earlier in the string — this is the one raw literal in an otherwise tokenized line. | +| `web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/HarnessSelectControl.tsx:40` | `pi_agenta: {label: "Pi (Agenta)", short: "Ag", color: "#1c2c3d"},` | data config, package | **TOKENIZE** → `var(--ag-colorText)` | +| `web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/HarnessSelectControl.tsx:72` | `return {label, short, color: "#586673"}` | JS fallback, package | **TOKENIZE** → `var(--ag-colorTextTertiary)` | + +### Recharts / chart config (stroke/grid colors, needs edit) + +| File:Line | Excerpt | Kind | Recommendation | +|---|---|---|---| +| `web/oss/src/components/EvalRunDetails/components/EvaluatorMetricsChart/BarChart.tsx:129` | `tickLine={{stroke: "#05172933"}}` | Recharts config, OSS | **TOKENIZE** → `var(--ag-rgba-051729-18)` (`33`/`ff` ≈ 0.2 alpha, closest defined step) | +| `web/oss/src/components/EvalRunDetails/components/EvaluatorMetricsChart/BarChart.tsx:139` | `stroke="#05172933"` | Recharts config, OSS | **TOKENIZE** → same as above | +| `web/oss/src/components/EvalRunDetails/components/EvaluatorMetricsChart/HistogramChart.tsx:102` | `tickLine={{stroke: "#05172933"}}` | Recharts config, OSS | **TOKENIZE** → `var(--ag-rgba-051729-18)` | +| `web/oss/src/components/EvalRunDetails/components/EvaluatorMetricsChart/HistogramChart.tsx:112` | `stroke="#05172933"` | Recharts config, OSS | **TOKENIZE** → `var(--ag-rgba-051729-18)` | +| `web/oss/src/components/EvalRunDetails/components/views/OverviewView/components/OverviewPlaceholders.tsx:124` | `` | Recharts config, OSS | **TOKENIZE** → `var(--ag-colorBorderSecondary)` | +| `web/oss/src/components/Evaluations/MetricDetailsPopover/assets/ResponsiveMetricChart.tsx:291` | `stroke="#05172933"` | Recharts config, OSS | **TOKENIZE** → `var(--ag-rgba-051729-18)` | + +### Tailwind arbitrary-value shadows / backgrounds (needs edit) + +| File:Line | Excerpt | Kind | Recommendation | +|---|---|---|---| +| `web/oss/src/components/DrillInView/DrillInFieldHeader.tsx:243` | `...shadow-[0_12px_32px_rgba(5,23,41,0.16)]"` | Tailwind arbitrary, OSS | **TOKENIZE** → `shadow-[0_12px_32px_var(--ag-rgba-051729-18)]` (no exact 0.16 step defined; 0.18 is closest) | +| `web/oss/src/components/DrillInView/TraceSpanDrillInView.tsx:517` | `...shadow-[0_8px_24px_rgba(5,23,41,0.12)] ...` (same className already uses `var(--ag-rgba-051729-14)` and `var(--ag-c-FFFFFF)` for its border/background) | Tailwind arbitrary, OSS | **TOKENIZE** → `var(--ag-rgba-051729-10)`; follow the file's own precedent two tokens earlier in the same className | +| `web/oss/src/components/pages/settings/Secrets/SecretProviderTable/index.tsx:124` | `className="bg-[#0517290F] px-2 py-[1px] m-0"` | Tailwind arbitrary, OSS | **TOKENIZE** → `bg-[var(--ag-c-0517290F)]` (legacy shim already defines this exact 8-digit value) or `bg-colorFillTertiary` | +| `web/oss/src/components/TemplateStrip/components/CopiedToast.tsx:33` | `...shadow-[0_10px_26px_rgba(5,23,41,0.35)] ...` | Tailwind arbitrary, OSS | **TOKENIZE**, flagged: no alpha step at 0.35 exists (steps jump 0.18 → 0.45). Either accept `var(--ag-rgba-051729-45)` (visibly stronger) or add a new `alphaFill` entry during the retheme. | +| `web/oss/src/components/EvalRunDetails/components/views/SingleScenarioViewerPOC/ScenarioAnnotationPanel/RunOverlay.tsx:12` | `bg-white/90 dark:bg-[#141414]/90` | Tailwind arbitrary, dark variant, OSS | **TOKENIZE** → drop the `dark:` branch entirely and use `bg-colorBgContainer/90` — `colorBgContainer` is already CSS-var-backed and theme-aware, so one class covers both modes (today's `bg-white/90` light branch is also technically hardcoded, just not part of this hex set). | + +### Plain CSS files (needs edit) + +| File:Line | Excerpt | Kind | Recommendation | +|---|---|---|---| +| `web/oss/src/styles/animations.css:40` | `stroke: #d6dee6;` (in `@keyframes colorChange`) | CSS keyframe, OSS | **TOKENIZE** → `var(--ag-zinc-3)`. Note the same keyframe hardcodes `#36cfc9` (teal, out of this audit's scope) as its other stop — fix together for a coherent animation. | +| `web/oss/src/styles/code-editor-styles.css:594` | `--editor-gutter-border: #303030;` (inside `.dark .code-editor .editor-code`) | CSS custom prop, OSS | **TOKENIZE** → `var(--ag-colorBorderSecondary)`. Already `.dark`-scoped, so this is a safe 1:1 swap. | +| `web/oss/src/styles/editor-theme.css:696` | `background: #141414;` (`.dark .toolbar`) | CSS, OSS (Lexical editor theme) | **TOKENIZE** → `var(--ag-colorBgContainer)` | +| `web/oss/src/styles/editor-theme.css:702` | `background-color: #303030;` (`.dark .toolbar .divider`) | CSS, OSS | **TOKENIZE** → `var(--ag-colorBorderSecondary)` | +| `web/oss/src/styles/editor-theme.css:720` | `border-left-color: #424242;` (`.dark .editor-quote`) | CSS, OSS | **TOKENIZE** → `var(--ag-colorBorder)` | +| `web/oss/src/styles/editor-theme.css:730` | `background-color: #141414;` (`.dark .editor-tableFrozenRow ...`) | CSS, OSS | **TOKENIZE** → `var(--ag-colorBgContainer)` | +| `web/oss/src/styles/editor-theme.css:741` | `border-color: #424242;` (`.dark .editor-tableCell`) | CSS, OSS | **TOKENIZE** → `var(--ag-colorBorder)` | +| `web/oss/src/styles/editor-theme.css:746` | `border-color: #424242;` (`.dark .editor-tableFrozenRow ... :after`) | CSS, OSS | **TOKENIZE** → `var(--ag-colorBorder)` | +| `web/oss/src/styles/evaluations.css:171` | `background-color: rgba(5, 23, 41, 0.2);` | CSS, OSS | **TOKENIZE** → `var(--ag-rgba-051729-18)` (closest existing step) | +| `web/oss/src/styles/globals.css:797` | `border-color: #d6dee6;` (`.auth-locked-input` disabled, light-only, no `.dark` counterpart today) | CSS, OSS | **TOKENIZE** → `var(--ag-colorBorderSecondary)` | +| `web/oss/src/styles/globals.css:842` | `background: #141414;` (`.dark .evaluation-filters .ant-segmented-item`) | CSS, OSS | **TOKENIZE** → `var(--ag-colorBgContainer)` | +| `web/oss/src/styles/globals.css:855` | `border-bottom-color: #303030 !important;` (`.dark .comparison-table` expanded row) | CSS, OSS | **TOKENIZE** → `var(--ag-colorBorderSecondary)` | +| `web/oss/src/styles/globals.css:873` | `border-color: #424242;` (`.dark .auth-locked-input` disabled) | CSS, OSS | **TOKENIZE** → `var(--ag-colorBorder)` | +| `web/oss/src/styles/human-evals.css:2` | `--not-available-stripe: #eaeff5;` (`body` level, no dark override in this file) | CSS custom prop, OSS | **TOKENIZE** → `var(--ag-colorBorderSecondary)` or `var(--ag-zinc-2)` | + +**Note on `editor-theme.css` / `code-editor-styles.css` / `globals.css`:** these three files +are the only place where dark-mode literals (`#141414`/`#303030`/`#424242`) are hand-written +inside real `.dark`-scoped selectors rather than sourced from `theme-variables.css`. They +don't import `palette.ts` at all — they're the biggest blind spot for the dark-mode leg of +the retheme, because a `pnpm generate:tailwind-tokens` run will silently leave all 9 of +these rules on the old navy-derived dark grays. + +### Already tokenized — `var(--ag-c-X, #fallback)` idiom (LEAVE) + +These use Tailwind's arbitrary-value syntax `[var(--ag-c-XXXXX, #fallback)]`. The CSS +custom property is always defined by `theme-variables.css` at `:root`, so the hex fallback +is inert — it only renders if the variable itself is undefined, which doesn't happen here. +No action needed; listed for completeness per the audit instructions. + +| File | Lines | Var used | +|---|---|---| +| `web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/ItemRow.tsx` | 108, 274 | `--ag-c-EAEFF5` | +| `web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/useModelHarness.tsx` | 483, 490, 493 | `--ag-c-EAEFF5`, `--ag-c-97A4B0` | +| `web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/CodeEditor.tsx` | 77 | `--ag-c-EAEFF5` | +| `web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/InstructionsDrawer.tsx` | 149, 170, 171, 199, 212, 219, 221 | `--ag-c-EAEFF5`, `--ag-c-586673`, `--ag-c-97A4B0`, `--ag-rgba-051729-04` | +| `web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/JsonObjectEditor.tsx` | 54 | `--ag-c-EAEFF5` | +| `web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/MarkdownEditor.tsx` | 216, 225, 236, 284, 317 | `--ag-c-97A4B0`, `--ag-c-EAEFF5`, `--ag-c-586673`, `--ag-c-BDC7D1` | +| `web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/SkillFormView.tsx` | 216, 225, 235 | `--ag-c-EAEFF5`, `--ag-c-586673`, `--ag-c-1C2C3D` | +| `web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/SkillUploadZone.tsx` | 60, 61, 68, 70 | `--ag-c-D6DEE6`, `--ag-c-586673`, `--ag-c-F5F7FA` | +| `web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/WorkflowReferenceSelector.tsx` | 478, 495 | `--ag-c-EAEFF5` | +| `web/packages/agenta-ui/src/components/presentational/section/ConfigAccordionSection.tsx` | 194, 228 | `--ag-c-586673`, `--ag-c-EAEFF5` | +| `web/packages/agenta-ui/src/Editor/MarkdownToolbar.tsx` | 78, 79, 314 | `--ag-c-586673`, `--ag-c-EAEFF5`, `--ag-c-1677FF` | + +### Needs a small fix even though partly var-backed + +| File:Line | Excerpt | Kind | Recommendation | +|---|---|---|---| +| `web/packages/agenta-annotation-ui/src/components/AnnotationSession/ConfigurationView.tsx:58` | `style={{borderBottom: collapsed ? undefined : "1px solid rgba(5,23,41,0.06)"}}` — one line above, the sibling `
` already uses `bg-[var(--ag-rgba-051729-02)]` correctly | inline style, package | **TOKENIZE** → `var(--ag-rgba-051729-06)` (exact match already exists; trivial fix) | +| `web/packages/agenta-annotation-ui/src/components/AnnotationSession/ConfigurationView.tsx:327` | `"var(--Components-Collapse-Global-lineWidth, 1px) solid var(--Colors-Neutral-Border-colorSplit, rgba(5, 23, 41, 0.06))"` | inline style, package (Figma-exported var name, hardcoded fallback) | **TOKENIZE** → replace the fallback with `var(--ag-colorSplit)` instead of the raw literal | +| `web/oss/src/components/EvalRunDetails/components/views/ConfigurationView/components/EvaluatorSection.tsx:224` | `"var(--Components-Collapse-Global-lineWidth, 1px) solid var(--Colors-Neutral-Border-colorSplit, rgba(5, 23, 41, 0.06))"` | inline style, OSS (same Figma-var pattern) | **TOKENIZE** → same fix, replace fallback with `var(--ag-colorSplit)` | + +### Comments (no functional risk, LEAVE) + +| File | Lines | Note | +|---|---|---| +| `web/packages/agenta-playground-ui/src/components/PlaygroundInputsBody/VariableCard.tsx:872` | `// beat the \`isEditorFocused && "!border-[#BDC7D1]"\` rule baked into` | Describes `SharedEditorImpl.tsx`'s actual rule, which is already `var(--ag-c-BDC7D1)` — the comment's shorthand is just stale/informal, not a real literal. **LEAVE**, optionally refresh the comment text for accuracy. | +| `web/packages/agenta-ui/src/utils/styles.ts` | 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 121, 127, 129, 131, 133, 137, 139, 141, 143, 145, 147, 149, 151, 163, 165, 167, 171, 183, 189, 191, 193, 195, 239, 241 | JSDoc header block ("Token Reference (from antd-tailwind.json): colorText: #1c2c3d …") and per-property doc comments listing the token's hex for human reference. No live code in this file uses raw hex — all real usage is via token-name strings (`colorText`, `colorTextSecondary`, …). **LEAVE**, recommend a documentation refresh pass after the retheme lands so these comments don't mislead the next reader, but zero functional/visual risk. | + +## 3. Build-time consumers of the generated token files + +Searched all of `web/` for importers of `tokens/antd-tailwind.json`, +`tokens/antd-themeConfig.json`, and `theme/antd-overrides.generated.ts`, plus any other +call to antd's `theme.getDesignToken()` or direct import of `palette.ts`. + +| Consumer | Imports | When it runs | Bakes values? | +|---|---|---|---| +| `web/oss/tailwind.config.ts` | `tokens/antd-tailwind.json` (spread into `theme.extend.colors`) | Tailwind JIT, at `next dev`/`next build` time (Node) | **No for the spread** — `antdTailwind` values are static hex from the last generator run, same as `theme-variables.css`; regenerating and rebuilding picks up new values normally. **Yes for the Tremor block** — see below. | +| `web/oss/src/components/Layout/ThemeContextProvider.tsx` | `tokens/antd-themeConfig.json` (as `antdTokens`, passed into antd's ``) and `theme/antd-overrides.generated.ts` (`DARK_TOKEN_OVERRIDES`, `darkComponents`) | Browser runtime, every render | **No** — these are read fresh from the committed JSON/TS files at runtime; once the generator is re-run and the files are committed, the running app picks up the new values with no separate build-time snapshot step. This is the expected, working path. | +| `web/scripts/generate-tailwind-tokens.ts` | `legacy-shim.ts`, `tokens/antd-themeConfig.json` (as its own output target), calls `theme.getDesignToken()` on the **dark** algorithm to snapshot dark values into `palette.ts`'s `componentsDark`/scale literals | Only when the generator itself is run | This is the generator — by design, it bakes a snapshot. Not a bug; it's how `palette.ts`'s dark literals were produced in the first place (see the file's own header comment). | + +### The Tremor brand-color block — confirmed baked, and already wrong today + +`web/oss/tailwind.config.ts` line 5: + +```ts +import {theme} from "antd" +const token = theme.getDesignToken() // called with NO seed/config argument +``` + +`theme.getDesignToken()` with no arguments returns antd's **stock default light seed +token**, not anything derived from Agenta's `ConfigProvider` (that provider only exists at +React runtime, in the browser; `tailwind.config.ts` runs in a separate Node process during +the Tailwind build step and has no access to it). Verified by running it directly: + +``` +colorPrimary: #1677ff +colorPrimaryBgHover: #bae0ff +colorPrimaryBorderHover: #69b1ff +colorPrimaryHover: #4096ff +colorPrimaryActive: #0958d9 +``` + +That's antd's default blue, not Agenta's navy (`#1c2c3d`) — and it won't be the new brand +color either. Both the `tremor.brand` and `dark-tremor.brand` blocks (lines 148–176, +177–206) read from this same static `token`, so light and dark Tremor brand colors are +currently **identical** (both antd-default-blue), which also can't be right for a +dark-aware chart palette. + +**Practical severity today:** low. `@tremor/react` is a declared dependency (its +`node_modules` path is even listed in `tailwind.config.ts`'s `content` array) but grepping +`web/oss/src` and `web/ee/src` finds **no component using `tremor-brand`/`dark-tremor` +classes** — only one file (`SectionPrimitives.tsx`) uses the unrelated `shadow-tremor-input` +box-shadow token. So nothing visibly breaks today. + +**Verdict:** it will **not** pick up the new `colorPrimary` automatically after +regeneration — it was never wired to Agenta's palette in the first place, seed-token or +CSS-var. It needs a code fix regardless of the retheme: either seed `theme.getDesignToken()` +with Agenta's actual light/dark tokens (e.g. import `antdTailwind`/`antdTheme` JSON instead +of calling the bare antd default), or point the `tremor.brand.*` keys straight at the +`--ag-color*` CSS vars the way `themeAwareColors` already does a few lines above it. Worth +fixing in the same PR that does the retheme, since otherwise it's a second stale-brand +surface waiting for the first real Tremor chart to ship. + +No other file in `web/` calls `getDesignToken()` or imports `palette.ts` directly for a +build-time snapshot. diff --git a/docs/design/agent-workflows/scratch/stale-llmops-positioning-sweep-2026-07-23.md b/docs/design/agent-workflows/scratch/stale-llmops-positioning-sweep-2026-07-23.md new file mode 100644 index 0000000000..c938827309 --- /dev/null +++ b/docs/design/agent-workflows/scratch/stale-llmops-positioning-sweep-2026-07-23.md @@ -0,0 +1,59 @@ +# Stale LLMOps positioning outside docs/ (sweep 2026-07-23) + +Agenta now positions as an agent-building platform (competitors: Gumloop, n8n, +ActivePieces). This sweep lists every surface outside the Docusaurus docs tree that +still carries the old LLMOps / prompt-management / observability positioning. +Mahmoud is fixing docs/ himself; this file covers everything else. + +Already clean (verified, no action): all root READMEs (en/cn/ja/kr), readme.new.md, +sdks/python README + pyproject, website homepage meta + JSON-LD, Base/Site layouts, +auth SideBanner, all AGENTS.md files, .github templates, CONTRIBUTING.md, gtm.txt. + +## Live marketing site (website/, highest impact first) + +| File | Line | Stale copy | +|---|---|---| +| `website/src/components/InlineCta.astro` | 13 | "Agenta is the open-source LLMOps platform: prompt management, evals, and LLM observability all in one place." (reused block, appears on every page that embeds it) | +| `website/src/pages/pricing.astro` | 36 | meta description: "...prompt management, evaluation, and observability built in." | +| `website/src/pages/blog/index.astro` | 43, 64 | "...prompt management, evaluation, and observability for LLM apps." (meta + on-page heading) | +| `website/src/pages/authors/index.astro` | 19 | "...writing on the Agenta blog about prompt management, evaluation, and LLM observability." | + +## Legacy blog archive (website/src/content/posts/) + +~17 of 37 posts are built around the old category or old competitor set +(Langfuse, LangSmith, Phoenix, Helicone, Humanloop comparisons; prompt-management +guides). Decide per post: rewrite, redirect, or keep as dated archive. Worst +offenders: `top-open-source-prompt-management-platforms.mdx`, +`top-llm-observability-platforms.mdx`, `top-llm-gateways.mdx`, +`humanloop-sunsetting-migration-and-alternative.mdx`, +`the-definitive-guide-to-prompt-management-systems.mdx`, +`open-source-llm-observability.mdx`, `building-the-data-flywheel-*.mdx` +(frontmatter says "LLMOps platform"). + +## In-product copy (web/) + +| File | Line | Stale copy | +|---|---|---| +| `web/oss/src/lib/onboarding/widget/config.ts` | 6-107 | Onboarding checklist categories: "Iterate on prompts", "Prompt management", "Run evaluation", "Tracing" (old workflow framing) | +| `web/oss/src/components/pages/evaluations/onlineEvaluation/EmptyStateOnlineEvaluation/EmptyStateOnlineEvaluation.tsx` | 12 | "Monitor and evaluate your LLM outputs in real-time..." | + +## Package metadata + +| File | Line | Stale copy | +|---|---|---| +| `web/_reference/agenta-sdk/package.json` | 6 | "core API client for prompt management, evaluation, and observability" (verify whether this `_reference/` copy is published anywhere before editing) | +| `web/_reference/agenta-sdk-tracing/`, `-mastra/`, `-ai/` package.json | 6/6/4 | tracing/prompt-centric descriptions | + +## Draft content (Agenta landing page pivot/ at repo root) + +`content/site.json` (lines 26, 30, 68, 74) and `content/pricing.json` (109, 120, +142) still carry the LLMOps hero/nav/footer copy; `handoff/landing-agents-copy.md` +line 140 has the old footer product list. Line 74 of site.json is the exact string +that leaked into the live `InlineCta.astro`. + +## Internal references (low priority, arguably fine) + +`.agents/analytics-api/ANALYTICS_API_COMPARISON.md` (Langfuse API comparison, an +engineering reference), `.agents/sso-login-flow/*` (Langfuse as auth precedent), +`.agents/skills/design-interfaces/SKILL.md:78` (langfuse as anti-pattern example), +`.agents/skills/write-issue/SKILL.md:91` ("prompt management" as a label name). diff --git a/docs/design/gtm/README-draft.md b/docs/design/gtm/README-draft.md new file mode 100644 index 0000000000..fdb2cbf49d --- /dev/null +++ b/docs/design/gtm/README-draft.md @@ -0,0 +1,248 @@ +

+ +

+ +

The open-source platform for building and running AI agents

+ +

+ Bring your models. Choose your harness. Build agents by working with them — then chat + with them, share them with your team, or run them in the background. +

+ +

+ Try Agenta Cloud free · + Self-host Agenta · + Documentation +

+ + + +

+ + +

+ +--- + +## Why Agenta? + +Most agent tools make you choose. Chat with an agent, but you can't shape it. Code one from +scratch, and you build the whole runtime yourself. Or draw a workflow before you know what +actually works. + +Agenta gives you one place to build an agent through real work, improve its instructions and +skills, and then put the same agent to work — for you or for your team. + +- **Choose your model** — your API keys, hosted models, OpenRouter, Hugging Face, or your own + models through Ollama and vLLM. +- **Choose your harness** — run the same agent on Pi or Claude today, with more coming. +- **Choose where it runs** — Agenta Cloud, your own machine, or a Daytona sandbox. +- **Build by working** — chat with the agent and improve it from real results, not from a blank + config form. +- **Put it to work** — share it with your team, call it from another agent, or run it on a + schedule or an event. +- **Keep improving it** — every change is versioned, every run is traced, every run has a cost. + +## What can you build? + +Start from a real job, not an empty agent. + + + +- **Research agent** — delegates to subagents, gathers evidence, and writes a sourced report. +- **Go-to-market agent** — researches a market, drafts the launch material, improves it with + your team. +- **Background monitor** — runs on a schedule or an event, investigates, and reports back. +- **Repository agent** — works through issues and code, then hands the repeatable part to a + background run. + +## Not a workflow builder + +A workflow follows the graph you drew. It does the same thing every time, in the same order, +and when reality doesn't match the graph, it breaks. + +An agent decides what to do next. It tries something, sees the result, and tries something +else. That's the point, and it's why you can't draw it in advance — you find out what works by +doing the work. + +If you know every step up front, use a workflow tool. If the job needs judgment, this is for +you. + +## What is an agent in Agenta? + +An agent is made of parts you can read and change: + +```text +Instructions + Skills + Tools and MCP servers = your agent +``` + +The model, the harness, and the runtime are **choices**, not permanent dependencies. Switch any +of them without redefining what the agent knows or how it works. + +Your agent can work in four ways: + +- **Interactive** — you work with it directly, in chat. +- **Collaborative** — your team uses the same agent, with the same skills and tools. +- **Composable** — another agent calls it as a subagent. +- **Background** — it runs on demand, on a schedule, or when something happens. + +## What's supported today + +Checked is what works now. Unchecked is what we're building — [tell us which one you want +next](https://github.com/Agenta-AI/agenta/discussions), or send a PR. + +**Harnesses** + +- [x] Pi +- [x] Claude +- [ ] Codex +- [ ] OpenCode +- [ ] Your harness — open an issue + +**Models** + +- [x] Provider API keys (OpenAI, Anthropic, Google, and others) +- [x] OpenRouter +- [x] Self-hosted models — Ollama, vLLM, Hugging Face +- [ ] Your Claude or ChatGPT subscription instead of an API key +- [ ] + +**Where agents run** + +- [x] Agenta Cloud +- [x] Your own machine, self-hosted +- [x] Daytona sandbox +- [ ] + +**How agents run** + +- [x] Interactively, in chat +- [x] On a cron schedule +- [x] On an app event +- [ ] Inbound webhook +- [ ] + + + +## From a conversation to a working agent + +1. **Start with a real task.** Work with the agent. Watch what it does. Correct it. +2. **Refine how it works.** Change its instructions, skills, tools, model, or harness. +3. **Put it to work.** Keep chatting with it, share it, call it from another agent, or give it a + schedule. +4. **Learn from every run.** Traces, versions, and cost data, so the next run is better. + +## See what your agents actually did + +Background agents don't fail loudly. A run reports success, and one step inside it quietly did +nothing. And an agent left alone with an API key can spend a lot of money by morning. + +So every run is traced. Every model call, every tool call, every file written, with what it +cost. When something goes wrong at 3am, you open the run and see exactly where — and what it +spent getting there. + +- **Tracing** — every agent, model, and tool call. +- **Versioning** — every change to instructions, skills, and configuration. Roll back anything. +- **Cost and usage** — per run, per agent. + + + +## Get started + +### Agenta Cloud + +The fastest way to try it. Free tier, no credit card. + +[**Try Agenta Cloud →**](https://cloud.agenta.ai?utm_source=github&utm_medium=referral&utm_campaign=readme) + +### Self-host Agenta + +You need Docker and Docker Compose. + +1. Clone Agenta: + +```bash +git clone https://github.com/Agenta-AI/agenta && cd agenta +``` + +2. Create the environment file from the example: + +```bash +cp hosting/docker-compose/oss/env.oss.gh.example hosting/docker-compose/oss/.env.oss.gh +``` + +3. Start the services: + +```bash +docker compose -f hosting/docker-compose/oss/docker-compose.gh.yml --env-file hosting/docker-compose/oss/.env.oss.gh --profile with-web --profile with-traefik up -d +``` + +4. Open `http://localhost`. + +For remote hosts and custom ports, see the +[self-hosting](https://agenta.ai/docs/self-host/quick-start?utm_source=github&utm_medium=referral&utm_campaign=readme) +and [remote deployment](https://agenta.ai/docs/self-host/guides/deploy-remotely?utm_source=github&utm_medium=referral&utm_campaign=readme) +docs. + +## What's rough today + +Agenta is early. We would rather you hear it from us than find out at 3am. + + + +- Small local models are unreliable on long tool-calling loops. Frontier models and the larger + local models hold up. Tell us what you're running. +- Only a handful of MCP servers are wired up out of the box. +- + +## What's next + +- More harnesses and more models +- More MCP servers, tools, and integrations +- Templates you can open with one click +- Better background operation +- Agents that improve themselves from their own traces + +Want a specific model, harness, or integration? +[Ask in Discussions](https://github.com/Agenta-AI/agenta/discussions) and we'll add it. + +## Community + +- [Documentation](https://agenta.ai/docs/?utm_source=github&utm_medium=referral&utm_campaign=readme) — guides and API reference +- [Changelog](https://agenta.ai/docs/changelog?utm_source=github&utm_medium=referral&utm_campaign=readme) — recent updates +- [Slack](https://join.slack.com/t/agenta-hq/shared_invite/zt-37pnbp5s6-mbBrPL863d_oLB61GSNFjw) — ask questions, get help +- [Report a bug](https://github.com/Agenta-AI/agenta/issues) +- [Share an idea](https://github.com/Agenta-AI/agenta/discussions) +- [Contributing guide](CONTRIBUTING.md) + + + +## Star Agenta + + + +## Contributors + + + +## Disabling Anonymized Tracking + +By default, Agenta automatically reports anonymized basic usage statistics. This helps us +understand how Agenta is used and track its overall usage and growth. This data does not +include any sensitive information. To disable anonymized telemetry set +`AGENTA_TELEMETRY_ENABLED` to `false` in your `.env` file. + +## License + +MIT. diff --git a/docs/design/gtm/gtm-wiki.md b/docs/design/gtm/gtm-wiki.md new file mode 100644 index 0000000000..a5a795b8ba --- /dev/null +++ b/docs/design/gtm/gtm-wiki.md @@ -0,0 +1,688 @@ +# Agenta Go-to-Market Wiki + +_Last updated: 2026-07-12 — instrumentation, assets, team, and experiment framing_ + +## Purpose + +This is the living source for organizing Agenta's GTM. It preserves founder context, distinguishes observations from hypotheses and decisions, and will gradually become an actionable launch and learning plan. + +## Operating context + +- Today is Sunday, 12 July 2026. +- The first public launch is planned for Tuesday, 14 July 2026. +- This is the first of many launches during an intensive 2–3 month GTM period. +- The immediate process is brain dump first, then synthesis, prioritization, milestones, and experiments. +- This file is updated after every relevant conversation turn. + +## Goal + +Find the initial segment for which the product already creates strong value and which can support rapid growth. Then optimize the product and messaging for that segment before expanding to adjacent segments. + +The segment is not yet known. GTM is therefore a discovery process, not just a distribution plan. + +## Product origin and founder evidence + +- The product is an MVP built primarily for the founder's own needs rather than for a validated segment. +- The founder wants it to become a primary everyday place to work. +- In practice, the founder particularly values talking with specialized agents, creating artifacts, retaining files and context, and sharing chats with a team. +- Chat and artifacts were not originally considered the main value proposition, but hands-on use has made them feel increasingly important. +- For implementation-heavy engineering work, the founder currently prefers Claude and engineering-specific workflows. This is evidence that Agenta is not currently the strongest complete environment for that job. + +## Product: factual capability map + +### Conversational work + +- ChatGPT-like conversations with configurable, specialized agents. +- Agents can help perform work, explain themselves, and help improve their own setup. +- Sessions can retain in-scope memory and durable files that users can inspect. +- The UI supports files and questionnaires, with richer artifacts and generative UI as part of the product direction. +- Chats and work can be shared within a team. + +### Agent configuration + +Agents can be configured with: + +- Instructions. +- Tools, including shell/coding tools and external integrations through Composio. +- MCP servers. +- Skills. +- Harnesses (including Claude, with Codex contemplated for the future). +- General and granular permissions. +- Scheduled and event-based triggers. + +### Interactive and background execution + +- An agent can work interactively through chat. +- It can also run when triggered by time or external events. +- A user can therefore develop work conversationally and potentially turn it into recurring background work. + +### Build kit and self-configuration + +- The Playground build kit lets an agent configure itself using platform tools. +- The intended experience is that a user describes what they want and the agent helps become the appropriate collaborator, workflow, or background automation. + +### Platform, governance, and portability + +- Agent configuration is versioned and earlier revisions can be inspected or restored. +- The platform retains tracing, observability, annotations, evaluations, and role-based access control from the previous Agenta product. +- Agents and skills can be shared across teams subject to access controls. +- Agenta is open source and self-hostable. +- Users can use their own model subscriptions or API keys. +- The configuration is general enough in principle to be loaded and run elsewhere, although the founder currently sees value in Agenta itself as the place to work. + +## Product boundaries and constraints observed so far + +- Flexibility is intentional; Agenta does not force work into rigid predefined workflow “tunnels.” +- Getting started should be conversational and accessible to somewhat technical users without requiring deep platform knowledge. +- Advanced use may still require learning or training. +- The breadth of capability creates a risk that the product is hard to explain without concrete workflows or templates. +- Tracing, evaluations, and orchestration are capabilities, but are not believed to be the primary user-facing value today. + +## Candidate segments + +These are unvalidated possibilities, not chosen ICPs: + +1. Startup founders/CEOs seeking a flexible AI work and automation environment. +2. Freelancers or automation consultants currently building workflows with tools such as n8n. +3. Tinkerers automating personal workflows and services such as Telegram. +4. Engineers automating bounded engineering-adjacent work. +5. Teams/businesses collaborating around shared agents, skills, chats, and workflows. +6. Non-technical users who want useful automation but do not know how to build it directly. + +### Engineering segment: current evidence + +Potential jobs include QA review, support cleanup, bounded task generation, and design-tool workflows. However: + +- Engineers often need code visibility and strong version-history workflows. +- They may need relatively few integrations compared with general business workflows. +- They already work in specialized environments where an automation may ultimately belong. +- Agenta may be useful for bounded engineering automations without being the best full engineering workspace. + +## Value proposition: current founder view + +The value is the work users can accomplish and automate—not “agent orchestration.” That phrase is engineering language, unclear to a general user, and does not express an outcome. + +There are two broad modes of value: + +1. **Interactive work:** an agent uses relevant skills, tools, integrations, context, and conversation to help complete work and create artifacts. +2. **Recurring work:** an agent handles repeat work on a schedule or in response to an event. + +A potentially important product progression is: + +**conversation → useful artifact/output → reusable agent workflow → triggered background work** + +This progression is a hypothesis, not yet a validated unique selling proposition. + +## Why templates may matter + +Templates could: + +- Explain the product through concrete jobs and outcomes. +- Reduce blank-page friction and time to first value. +- Package the product for different segment hypotheses. +- Let GTM test demand by use case before choosing an ICP. +- Help users progress from interactive work to repeat automation. + +No specific template set has yet been selected. + +## Positioning hypotheses—not decisions + +Possible category descriptions: + +- AI automation platform. +- Conversational agent workspace. +- Agent workspace. +- Agent build kit. + +Possible message territories: + +- Start with a conversation; turn it into work that runs itself. +- Automate the recurring work that consumes your week. +- Give an AI agent the skills and tools to handle a real workflow. +- Chat, create the output, then automate the workflow. + +The final category, message, ICP, and USP remain open. + +## What is explicitly not decided + +- Initial ICP or buyer. +- Initial use case or template wedge. +- Product category. +- Primary value proposition or USP. +- Tuesday's launch scope, audience, channel, offer, CTA, or success criterion. +- Pricing or packaging. +- The milestones for the first week, first month, or 2–3 month period. + +## Missing context to collect + +### Product readiness + +- What can a new user reliably do today from signup to completed outcome? +- Which capabilities are live, partially working, future-facing, or internal-only? +- Where does onboarding fail or require founder assistance? +- What are the known reliability, UX, security, and deployment constraints? + +### Existing users and evidence + +- Who has used the new product besides the founder? +- What did they attempt, complete, repeat, or abandon? +- Which moments caused delight or confusion? +- Are there users, waitlists, former Agenta users, community members, or design partners available for discovery? +- What usage, retention, trace, or qualitative data already exists? + +### Founder workflow evidence + +- Which exact workflows does the founder use today? +- How frequently, and what did they replace? +- What outcomes or artifacts are produced? +- Which workflow creates enough value that losing it would hurt? +- How much setup or intervention is currently required? + +### Market and alternatives + +- Which products do prospective users use instead for each job: ChatGPT/Claude, n8n, Gumloop, cowork products, coding agents, human assistants, scripts, or manual work? +- Why would a user switch, and what would prevent switching? +- Which parts of the proposed experience are actually differentiated versus simply expected? + +### Distribution assets and constraints + +- Founder audience, company audience, email list, former-user base, communities, partners, and available launch channels. +- Current website, domain, demo, screenshots, videos, templates, docs, onboarding, and signup flow. +- Team capacity for product fixes, content, onboarding calls, and support over the next two weeks. +- Budget and appetite for hands-on founder-led onboarding. + +### Business constraints + +- Desired business model and whether the near-term objective is learning, usage, revenue, fundraising evidence, or community growth. +- Pricing assumptions, hosting economics, model costs, and self-hosted versus cloud priorities. +- Geographic, regulatory, security, or procurement constraints. + +### Tuesday deadline + +- Why Tuesday is the chosen date. +- What “launch” means operationally. +- What must be true for it to proceed. +- What audience can realistically be reached by then. +- What signal the launch should produce. + +## Working method from here + +Continue separating: + +- **Context/evidence:** what exists or has happened. +- **Hypotheses:** what we believe may be true. +- **Decisions:** choices we intentionally make. +- **Experiments:** actions designed to resolve uncertainty. +- **Results:** evidence produced by those experiments. + + +## Context update: readiness, audience, urgency, and launch model + +### Product readiness as of 12 July 2026 + +Working now, though with rough edges: + +- Creating a new agent. +- Chatting with an agent. +- Changing agent configuration. +- Adding tools. +- Adding triggers. +- Running the core conversational/configuration loop demonstrated in this GTM project. + +Current limitations: + +- The experience is not polished or fully reliable. +- Bugs remain and new users may encounter failures. +- Documentation and videos are not yet sufficient to eliminate assistance. +- Broader behavioral and qualitative usage evidence does not yet exist. +- The product is only now reaching initial readiness; the live self-hosted release is expected by the end of Monday or on Tuesday. + +This readiness level makes tolerant, self-directed early adopters more appropriate than mainstream or enterprise users. + +### Product and distribution model + +- Agenta is open source and MIT-licensed. +- The immediate product to drive adoption for is the self-hosted version. +- The open-source community is central to both product development and GTM. +- Desired early users will try imperfect software, investigate problems, create GitHub issues, discuss failures in Slack, and potentially contribute tests or fixes. +- The team will create launch materials now, including website updates, documentation, self-hosting documentation, and videos. + +### Evidence available today + +Current users/testers are limited to: + +- The founder. +- The Agenta team. +- Friends and family. + +There are no meaningful behavioral, retention, or qualitative datasets yet. The founder has only just begun using the new Agenta in a real workflow; this GTM agent/chat is the first substantive workflow and real project. Therefore, claims about repeat usage, strongest workflows, or retention remain premature. + +### Existing reach + +- Approximately 20,000 people are on Agenta's mailing list. +- The founder has approximately 6,000 LinkedIn followers. +- There are no current design partners, and enterprise design-partner selling is not a priority for this phase. +- Reddit, X/Twitter, GitHub, Slack, self-hosting communities, direct outreach, and the existing audience are possible distribution surfaces. +- Tuesday is not intended to be a maximal LinkedIn/Product Hunt-style launch. + +The size of the existing audiences is known; their fit with the pivoted product is not. + +### Immediate business constraint + +- The company expects to need fundraising in roughly two months as runway becomes constrained. +- The near-term objective is not revenue. +- The overriding objective is adoption: self-hosted installs, usage, community participation, learning, and open-source growth. +- Product and GTM work should avoid revenue-oriented distractions during this phase unless the constraint changes. +- The ambition is for Agenta to become the default open-source alternative to products such as ChatGPT-style and Claude-style agent/work experiences for creating and using agents. + +### Desired early-adopter characteristics + +The initial users should ideally be: + +- Curious tinkerers and self-directed learners. +- Comfortable with technical concepts and self-hosting. +- Tolerant of bugs and rough edges. +- Able to create many agents or identify many workflows. +- Motivated to learn about agents and AI automation. +- Willing to report issues and participate publicly in a community. +- Likely to talk about Agenta and show what they build. +- Part of a segment that can expand beyond a tiny niche over time. + +Candidate communities within this profile include: + +- Early-career engineers moving into AI. +- AI/automation tinkerers. +- Technical startup founders. +- Potentially automation freelancers, although their early-adopter fit is less certain. + +Long-term, enterprise and business use may become attractive because Agenta already has foundations such as RBAC and SSO. However, current reliability and quality are not sufficient for that market, and enterprise is not the immediate GTM focus. + +### Competitive context: initial founder view + +Compared with general AI chat/work products such as ChatGPT and Claude: + +- Agenta offers greater configurability and flexibility. +- Users can switch models and harnesses and potentially use self-hosted models. +- Agenta is open source and self-hostable. +- It supports explicit agent workflows, tools, skills, triggers, and inspectable configuration. + +Compared with workflow products such as Gumloop and n8n: + +- Agenta is more conversational and agent-centric. +- It exposes more of the internal configuration and is more suitable for technically curious users who want to manipulate the underlying system. +- It is open source and MIT-licensed. +- It combines interactive chat/artifact work with triggered workflows. + +These are initial comparison points, not yet verified differentiation. Some competitors are moving toward similar agent experiences, so several advantages may be temporary. The durable importance of openness, licensing, self-hosting, model/harness choice, and community still needs validation. + +### Why launch now + +- The pivot began approximately one month ago. +- A working product now exists and further learning requires putting it in users' hands. +- Feedback and real-world use are more valuable at this stage than waiting for polish. +- Tuesday should be treated as the start of continuous exposure and iteration, not the definitive launch. + +### Launch model + +The intended model is repeated launching rather than a single event: + +- Begin on Tuesday with a focused early-adopter/self-hosted release. +- Use targeted communities, outreach, and potentially small paid-message tests rather than spending all attention on a broad splash. +- Continuously test segments, use cases, templates, messaging, positioning, and value propositions. +- Ship product, documentation, templates, and content repeatedly over the following weeks. +- Build enough exposure that tests produce meaningful signals; one isolated launch is insufficient. +- Aim to accumulate learning and momentum toward a larger launch in roughly 4–6 weeks. +- Relaunching several times is acceptable and expected. + +### Primary learning loop + +The near-term GTM loop is: + +1. Put the self-hosted product in the hands of suitable early adopters. +2. Observe who installs it, what they try to build, what works, where they fail, and whether they return. +3. Gather feedback and community participation through GitHub and Slack. +4. Segment users by persona, workflow, behavior, and value received. +5. Create and distribute workflow templates and messaging for promising segments. +6. Improve the product rapidly around demonstrated usage. +7. Repeat launches and experiments until a segment/message/workflow combination shows strong adoption potential. + +### Metrics not yet defined + +“Adoption” is the priority, but its operational definition remains open. Candidate measures to evaluate later include: + +- Self-hosted installations and successful first runs. +- Agents created and meaningfully used. +- Workflows completed and triggered repeatedly. +- Return usage. +- GitHub stars, issues, contributors, and pull requests. +- Slack joins and useful participation. +- Templates installed, used, or shared. +- Users publicly sharing what they built. + +No target numbers or primary metric have been selected yet. + + +## Context update: instrumentation, assets, audience, and team + +### Instrumentation + +- The product is instrumented with PostHog, including the open-source product. +- Additional activities/actions can be added as needed. +- The team can analyze retention and churn. +- This means segment experiments can be evaluated using behavior after acquisition, not only clicks or stated interest. + +### Templates + +- The founder reports that 23 templates already exist in the product. +- The currently injected build-kit template index exposes 28 playbooks across engineering, support, sales, monitoring, knowledge, and operations. These may not be the same set as the founder's 23 product templates, so the exact product inventory still needs reconciliation rather than assuming they are identical. +- The visible playbook categories suggest broad workflow coverage, but coverage itself does not establish which templates are ready, compelling, or suitable for the initial audience. + +### Existing community and audience + +- The GitHub repository has approximately 4,000 stars. +- Slack membership was stated as approximately “F500,” likely around 500, but the exact number should be confirmed. +- The 20,000-person mailing list consists of people who joined the previous Agenta product, generally AI engineers and AI product people. +- The founder estimates roughly 20% of the mailing-list audience may fit the new product, implying a plausible reachable pool of about 4,000 people. This is an estimate, not observed conversion data. + +### Team capacity + +The team is five core people plus two juniors: + +- Founder/CEO, currently central to product and shifting substantial focus toward GTM. +- CTO. +- Product engineer. +- Another main engineer. +- One additional core team member (role not yet specified). +- Two juniors. + +The team is small, and the founder is currently the main person focused heavily on product and soon GTM. Experiments must therefore be fast, reusable, instrumented, and light on bespoke support. + +### Assets and Tuesday readiness + +- A designed website and current version already exist; copy and cleanup are needed. +- GitHub exists and has meaningful existing attention; repository messaging needs updating. +- Documentation is outdated but can be revised. +- Tuesday work is focused on bug cleanup and producing/updating necessary artifacts rather than building a wholly new product. + +## Initial segment hypotheses for testing + +These hypotheses are deliberately narrower than “everyone who can automate.” All target open-source-friendly early adopters. + +### H1 — Technical founders and small startup operators + +**Situation:** They personally span product, engineering, support, and GTM and repeatedly stitch together AI tools to do work. + +**Why Agenta may fit:** The founder is a member of this segment; they have many cross-functional workflows, tolerate technical products, value flexible chat plus automation, and can become visible advocates. + +**Risk:** “Founder” is broad, pain may be diffuse, and many already live in ChatGPT/Claude plus scripts. + +### H2 — AI engineers and AI product builders who want internal automations outside the coding loop + +**Situation:** They understand agents and want to automate support, QA, research, reporting, triage, or internal operations without building and maintaining a custom system. + +**Why Agenta may fit:** Strong overlap with the existing mailing list and GitHub audience; high ability to self-host, debug, report issues, and appreciate model/harness choice and inspectable configuration. + +**Risk:** They may prefer building directly or expect a developer-first code environment Agenta is not optimized to replace. + +### H3 — AI automation tinkerers and aspiring agent builders + +**Situation:** They actively experiment with n8n, MCP, local/self-hosted AI, Telegram bots, and new agent frameworks, and want a powerful environment they can inspect and modify. + +**Why Agenta may fit:** High tolerance for rough edges, strong open-source affinity, many experiments, and likelihood of sharing what they build. + +**Risk:** High curiosity may produce stars and one-time trials without durable workflow usage. + +### H4 — Technical automation freelancers/consultants + +**Situation:** They build multiple workflows for clients and need reusable agents, templates, integrations, and a shareable/self-hosted environment. + +**Why Agenta may fit:** Repeated use cases, natural template creation, and potential distribution into client organizations. + +**Risk:** Reliability, client management, deployment, and support needs may already exceed MVP readiness. + +### Initial prioritization hypothesis + +Start by comparing H1, H2, and H3. Keep H4 observational until evidence shows freelancers can use the current product without reliability or client-delivery blockers. Do not target generic non-technical users or enterprise teams in this phase. + +## Proposed experiment system + +### Experiment 0 — Establish cohort instrumentation + +**Action:** Define acquisition source, declared persona, selected template/workflow, successful self-host, first agent created, first meaningful run, first triggered run, return usage, and community action as trackable properties/events. + +**Learning:** Whether later experiments create installation, activation, and repeat behavior—not merely traffic or stars. + +### Experiment 1 — Existing-audience segment invitation + +**Action:** Send small, separately tagged invitations to matched slices of the existing mailing list/community. Use one message for AI engineers/product builders and another for technical founders/operators. Invite them to self-host, try one concrete workflow, and join/report through GitHub or Slack. + +**Learning:** Which segment/message combination produces qualified clicks, successful installs, activation, return usage, and issue/community participation. + +**Important:** Start with small batches rather than the entire 20,000-person list so messaging can improve without exhausting the audience. + +### Experiment 2 — Persona-specific landing/message tests + +**Action:** Create lightweight pages or page variants for H1, H2, and H3. Keep the product constant while changing the user, problem, example workflows, and proof. Drive small targeted traffic from Reddit/X or direct outreach. + +**Learning:** Which framing attracts the right users and which workflow promise causes them to attempt installation. + +### Experiment 3 — Template wedge test + +**Action:** Select 2–3 ready templates for each leading segment, grouped around coherent jobs rather than platform categories. Present one recommended starting workflow per message/page rather than all templates at once. + +**Learning:** Which jobs generate completed first runs, repeat use, and requests for adjacent workflows. This distinguishes curiosity about agents from demand for actual work. + +### Experiment 4 — Concierge observation without enterprise selling + +**Action:** Recruit 5–8 suitable early adopters across the leading hypotheses. Watch or closely review their installation and first workflow, then examine their traces/events and conduct short follow-ups. + +**Learning:** Where activation fails, what users think the product is, which capabilities matter, and whether their second workflow emerges naturally. + +### Experiment 5 — Community-channel probes + +**Action:** Publish distinct, useful demonstrations in narrowly relevant communities: self-hosting/open-source, agent/MCP builders, technical founders, and automation tinkerers. Each post should show a concrete workflow and use tagged links. + +**Learning:** Which communities yield activated users and contributors rather than only engagement. It also reveals the language each community uses for the problem. + +### Experiment 6 — Conversation-to-automation proposition test + +**Action:** Demonstrate the same task in two stages: accomplish it interactively in chat, then configure it to recur through a trigger. Compare this message against a simpler “open-source AI automation” message. + +**Learning:** Whether the conversation → artifact → recurring work mechanism is understood, differentiated, and motivating. + +### Experiment 7 — Open-source proposition test + +**Action:** Compare emphasis on (a) open source/self-hosting and control, (b) model/harness flexibility, and (c) concrete workflow outcomes, while keeping audience and workflow stable. + +**Learning:** Whether openness drives adoption itself, acts as trust/proof after an outcome attracts users, or appeals primarily to low-retention tinkerers. + +## Research stream, not yet executed + +Segment research on Reddit and X should run alongside launch experiments. Agents can collect posts, recurring complaints, existing tool stacks, vocabulary, and evidence of attempted workflows. Research should begin narrowly by segment/problem and preserve source links and exact language. It should inform hypotheses but not substitute for observed product use. + +## Reddit research handoff + +A separate research session will investigate Reddit evidence for the initial segment, workflow wedge, competitor gaps, and market language. It should map relevant subreddits, personas, concrete use cases, n8n and other competitor complaints, self-hosting motivations, and terms such as agents, background agents, loops, workflows, AI automation, harnesses, skills, and MCP. + +The research must preserve source links, dates, short verbatim quotes, contradictory evidence, and a clear separation between observations and interpretations. Findings should inform segment and messaging experiments but should not be treated as final positioning decisions without product-usage evidence. + +Handoff: `reddit-research-handoff.md`. + + +### Reddit research refinement: community-first mapping + +The research should begin with a broad community map and then identify personas and interests within each community. Additional seed communities include AI_Automation, AI_Agents, LLMDevs, AutoGPT, ClaudeAI, OpenSourceAI, OpenAIDev, and especially CrewAI; exact subreddit names and activity must be verified. + +Community discovery should expand through public cross-posts and aggregate participation patterns from contributors in high-signal threads. Profile review may be used to infer broad builder/persona archetypes and discover adjacent communities, but the output should not create personal dossiers or retain usernames. + +For each community, research should cover size, activity, persona clusters, career/learning interests, what members build, verticals and application types, workflows/tasks, current excitement, recurring problems, and failures. Topic deep dives should prioritize experiences adjacent to Agenta: Claude's emerging work/agent capabilities, new n8n AI/chat features, chat-agent construction, CrewAI, background execution, loops, triggers, self-hosted agents, and durable interactive work. + +## Planned GTM synthesis artifact + +The next major artifact should be a downloadable, self-contained visual report connecting communities, personas, workflows/use cases, pains, competitive alternatives, and campaign experiments. It should include special attention to the self-hosting community and provide 2–3 positioning systems with corresponding UVP/USP, message variants, landing-page concepts, channels, and measurable tests. Work will begin after selecting and committing an appropriate set of reusable GTM and presentation skills. + +## Interactive GTM opportunity map + +A self-contained interactive report has been created at `agenta-gtm-opportunity-map.html`. It connects communities, personas, use cases, activation behaviors, positioning systems, campaign experiments, and source evidence. It includes a dedicated self-hosting strategy section and can be opened locally or printed to PDF. + +## Three integrated GTM hypotheses + +These are three coherent persona–position–message–channel systems to test independently. They are hypotheses, not final positioning. Do not combine all three into one homepage before behavioral evidence identifies the strongest segment × job × message combination. + +### Hypothesis 1 — Technical founders: work that keeps running + +#### Persona and situation + +Technical founders and small-team operators handling GTM, research, support, product, and operations themselves. They already use ChatGPT or Claude for useful work, but the work remains in disconnected conversations and must be repeated manually. + +#### Positioning + +- **Category:** Open-source AI workspace. +- **Positioning statement:** For technical founders who repeatedly use AI across their company, Agenta is an open-source workspace that turns useful conversations into reusable workflows that keep running. +- **Value proposition:** Complete a job with an agent through conversation, preserve its artifacts and context, then trigger that workflow automatically. +- **Candidate USP/mechanism:** Conversation → artifact → reusable agent → recurring background work. + +#### Messaging + +- **Headline:** Start with a conversation. Turn it into work that runs itself. +- **Subhead:** Build an agent for a real founder workflow, refine it through chat, then schedule or trigger it—without giving up control of its tools, files, or configuration. +- **Pillars:** Specialized agents instead of restarting generic chats; durable project context and artifacts; automation for work worth repeating. + +#### Initial workflow wedges + +- Weekly market and competitor brief. +- Customer-feedback synthesis. +- Support triage. +- Launch planning and content production. +- Daily company or product briefing. + +#### Channels + +- Founder LinkedIn and X audiences. +- A small tagged cohort from the existing mailing list. +- Founder communities such as r/startups, r/SaaS, and r/SideProject, subject to community rules. +- AI-agent communities. +- Direct outreach to technically capable founders. + +#### Experiment + +Create three founder landing-page variants centered on market intelligence, support triage, and the broader founder-workspace promise. The campaign demonstration should show a conversation producing a market brief and then becoming a weekly triggered workflow. + +- **Primary metric:** Meaningful first workflow completed. +- **Secondary metrics:** Trigger configured; second run completed; second related workflow created; return within seven days. +- **Decision rule:** Advance when founders naturally progress from one interactive workflow to repeat usage. Clicks alone do not validate the hypothesis. +- **Main risk:** “Technical founder” may be too broad and the pain may remain diffuse. + +### Hypothesis 2 — Self-hosting and local-AI builders: own your agent workspace + +#### Persona and situation + +Self-hosting operators, local-AI builders, MCP tinkerers, and developers who prioritize control, privacy, portability, customization, and model choice. “Self-hoster” is a motivation layer rather than a complete ICP; the research must still identify the repeated workflow. + +#### Positioning + +- **Category:** Self-hosted agent workspace. +- **Positioning statement:** For technical users who want capable agents without surrendering control, Agenta is the open-source agent workspace that runs with their models, tools, data, and infrastructure. +- **Value proposition:** Create ChatGPT/Claude-like agent experiences that users can inspect, customize, self-host, and keep. +- **Candidate USP/mechanism:** MIT-licensed workspace + model/harness choice + inspectable configuration + interactive and triggered execution. + +#### Messaging + +- **Headline:** Your agents. Your models. Your infrastructure. +- **Subhead:** Build conversational and background agents with your own models, tools, MCP servers, files, and triggers—in an open-source workspace you control. +- **Pillars:** Self-host the complete workspace; switch models and tools; inspect configuration, permissions, versions, and traces; avoid dependence on one vendor. + +#### Initial workflow wedges + +- Local personal or work agent. +- Scheduled local intelligence agent. +- Telegram-connected agent. +- MCP-powered desktop or browser agent. +- Private team knowledge or operations agent. + +#### Channels + +- r/selfhosted and r/LocalLLaMA, subject to community rules. +- Open-source AI communities and directories. +- Hacker News. +- GitHub. +- Self-hosting newsletters and creators. +- X audiences focused on local AI and open source. + +#### Experiment + +Drive the same concrete self-hosted workflow with three message variants: + +1. Ownership: “Your agents, models, and infrastructure.” +2. Privacy/control: “Your work does not have to live inside someone else’s AI.” +3. Workflow outcome: “Self-host an agent that prepares your daily brief.” + +- **Primary metric:** Successful self-host followed by a meaningful workflow run. +- **Secondary metrics:** Repeated run; local-model connection; issue, contribution, or public workflow share. +- **Guardrail:** Stars, installs, and architecture discussion without meaningful repeated use. +- **Decision rule:** Use self-hosting as the primary position only if control-led acquisition creates retained workflows. Otherwise, treat open source as proof supporting an outcome-led position. +- **Main risk:** Architecture enthusiasm may attract high-curiosity, low-retention users. + +### Hypothesis 3 — AI and automation builders: agent workflows you can operate + +#### Persona and situation + +AI engineers, AI product builders, advanced n8n users, and agent-framework experimenters building internal automations outside their primary coding loop. Agent demos are easy, but workflows that remain understandable, debuggable, and reliable are difficult. Visual automations become tangled while code-first frameworks require plumbing and maintenance. + +#### Positioning + +- **Category:** Open-source AI workflow platform. +- **Positioning statement:** For AI builders moving beyond demos, Agenta provides an interactive way to build, run, inspect, version, and improve agent workflows. +- **Value proposition:** Move from an agent prototype to repeatable work without assembling the entire configuration, execution, observability, and collaboration layer. +- **Candidate USP/mechanism:** Conversational building + explicit configuration + triggers + versioning + tracing/evaluations. + +#### Messaging + +- **Headline:** Build agent workflows you can actually operate. +- **Subhead:** Create the workflow through conversation, inspect every configuration, run it interactively or in the background, and improve it with traces and version history. +- **Pillars:** Build with chat without hiding the system; inspect tools, skills, permissions, and configuration; diagnose and revise failures; move between interactive and scheduled execution. + +#### Initial workflow wedges + +- QA review. +- Support triage. +- Feedback clustering. +- Monitoring and reporting. +- Research workflows. +- Agent evaluation and debugging. + +#### Channels + +- r/n8n, r/LangChain, r/crewai, and r/AI_Agents, subject to community rules. +- AI engineer and AI product segments of the existing mailing list. +- GitHub. +- Technical X audiences. +- Framework and MCP communities. + +#### Experiment + +Demonstrate a workflow failing to call a tool, then show Agenta exposing the trace and configuration, revising the agent, rerunning it, and versioning the fix. Test three landing-page messages: + +1. “From agent demo to operable workflow.” +2. “When your n8n AI workflow becomes difficult to debug.” +3. “Build through conversation without hiding the configuration.” + +- **Primary metric:** First successful meaningful workflow completed. +- **Secondary metrics:** Failed run successfully revised; second run; seven-day return usage. +- **Guardrail:** Attracting developers who expect Agenta to replace their coding environment. +- **Decision rule:** Advance if users repeatedly operate bounded workflows in Agenta. Narrow or reject if they only inspect the platform and return entirely to code. +- **Main risk:** Reliability is still rough, so “operate” must not imply production readiness that has not been demonstrated. + +### Current prioritization + +1. **Technical founders:** strongest founder/product-story fit. +2. **AI and automation builders:** strongest fit with the reachable mailing-list and GitHub audience. +3. **Self-hosting/local-AI builders:** strongest distribution and trust wedge, with the highest risk of architecture-led curiosity without workflow retention. + +### Shared experiment architecture + +- Give each hypothesis its own landing page, workflow demonstration, acquisition tags, and PostHog cohort. +- Keep GitHub as the open-source technical-evaluation destination, while making the landing page explain the user outcome. +- Compare qualified GitHub/install behavior, meaningful activation, repeated runs, and community contribution separately. +- Use small audience batches and controlled paid-message tests before exposing the full existing audience. +- Do not select a winner from CTR alone; require downstream activation and return behavior. diff --git a/docs/design/gtm/pricing-page-revision-3.json b/docs/design/gtm/pricing-page-revision-3.json new file mode 100644 index 0000000000..e0e83ad756 --- /dev/null +++ b/docs/design/gtm/pricing-page-revision-3.json @@ -0,0 +1,907 @@ +{ + "_note": "Pricing page revision 3. Every visible string must come from this file. The hosting toggle changes both the plan cards and the comparison table. Render comparisonByMode[currentMode]. In comparison rows, true renders a check, false renders a dash, and a string renders as-is.", + "hero": { + "eyebrow": "Pricing", + "headline": "Unlimited users. Unlimited agents and workflows. Pay only when your agents run.", + "description": "Start free with two team members. Every paid plan includes unlimited users, agents, workflows, schedules, and triggers." + }, + "hostingToggle": { + "defaultMode": "cloud", + "options": [ + { + "mode": "cloud", + "label": "Agenta Cloud", + "sublabel": "(we host)" + }, + { + "mode": "selfHosted", + "label": "Self-hosted", + "sublabel": "(you host)" + } + ] + }, + "plansByMode": { + "cloud": [ + { + "id": "hobby", + "name": "Hobby", + "tagline": "For individuals exploring Agenta.", + "price": "$0", + "unit": "forever", + "chip": null, + "cta": { + "label": "Start for free", + "href": "https://cloud.agenta.ai/", + "style": "outline" + }, + "includesLabel": "Includes", + "features": [ + "2 team members", + "Unlimited projects", + "Unlimited agents and workflows", + "5,000 agent runs / month", + "1-week trace data retention", + "Community support through GitHub Issues" + ] + }, + { + "id": "pro", + "name": "Pro", + "tagline": "For teams running agents in production.", + "price": "$29", + "unit": "/month", + "chip": "Unlimited users", + "cta": { + "label": "Upgrade to Pro", + "href": "https://cloud.agenta.ai/", + "style": "primary" + }, + "includesLabel": "Everything in Hobby, plus", + "features": [ + "Unlimited team members", + "Unlimited schedules and event triggers", + "10,000 agent runs / month included", + "$5 per additional 10,000 runs", + "Unlimited evaluations", + "1-month trace data retention", + "Community support through GitHub Issues" + ] + }, + { + "id": "business", + "name": "Business", + "tagline": "For teams that need governance, compliance, and priority support.", + "price": "$299", + "unit": "/month", + "chip": "Unlimited users", + "cta": { + "label": "Upgrade to Business", + "href": "https://cloud.agenta.ai/", + "style": "outline" + }, + "includesLabel": "Everything in Pro, plus", + "features": [ + "10,000 agent runs / month included", + "$5 per additional 10,000 runs", + "Team roles and role-based access control", + "SSO", + "SOC 2 Type II report", + "3-month trace data retention", + "Priority support", + "Private Slack Connect channel" + ] + }, + { + "id": "enterprise-cloud", + "name": "Enterprise", + "tagline": "For organizations that need advanced controls and dedicated support.", + "price": "Custom", + "unit": "", + "chip": null, + "cta": { + "label": "Book a demo", + "href": "https://cal.com/mahmoud-mabrouk-ogzgey/demo", + "style": "outline" + }, + "includesLabel": "Everything in Business, plus", + "features": [ + "Custom usage and trace data retention", + "Audit logs", + "Custom domains", + "Custom security and legal terms", + "Deployment and onboarding support", + "Dedicated support", + "Private Slack Connect channel", + "Custom service-level agreement" + ] + } + ], + "selfHosted": [ + { + "id": "open-source", + "name": "Open source", + "tagline": "The open-source agent workspace, running on your infrastructure.", + "price": "Free", + "unit": "forever", + "chip": null, + "cta": { + "label": "Read the docs", + "href": "https://docs.agenta.ai", + "style": "outline" + }, + "includesLabel": "Includes", + "features": [ + "Unlimited users and projects", + "Unlimited agents and workflows", + "Unlimited schedules and event triggers", + "Build and use agents through chat", + "Bring your own models and credentials", + "Instructions, skills, tools, integrations, and MCP servers", + "Files and persistent context", + "Shared agents and conversations", + "Role-based access control", + "SSO", + "Tracing, evaluations, and versioned agent configuration", + "Docker Compose deployment", + "Community support through GitHub Issues" + ] + }, + { + "id": "enterprise", + "name": "Enterprise", + "tagline": "For organizations that need advanced controls and commercial support on their own infrastructure.", + "price": "Custom", + "unit": "", + "chip": null, + "cta": { + "label": "Book a demo", + "href": "https://cal.com/mahmoud-mabrouk-ogzgey/demo", + "style": "primary" + }, + "includesLabel": "Everything in Open source, plus", + "features": [ + "Audit logs", + "Custom domains", + "Deployment and upgrade support", + "Security and architecture reviews", + "Priority support", + "Private Slack Connect channel", + "Dedicated support", + "Custom service-level agreement" + ] + } + ] + }, + "comparisonByMode": { + "cloud": { + "title": "Compare Agenta Cloud plans", + "columns": [ + { + "name": "Hobby", + "price": "Free" + }, + { + "name": "Pro", + "price": "$29/mo" + }, + { + "name": "Business", + "price": "$299/mo" + }, + { + "name": "Enterprise", + "price": "Custom" + } + ], + "groups": [ + { + "title": "Usage and limits", + "rows": [ + { + "label": "Team members", + "cells": [ + "2", + "Unlimited", + "Unlimited", + "Unlimited" + ] + }, + { + "label": "Projects", + "cells": [ + "Unlimited", + "Unlimited", + "Unlimited", + "Unlimited" + ] + }, + { + "label": "Agents and workflows", + "cells": [ + "Unlimited", + "Unlimited", + "Unlimited", + "Unlimited" + ] + }, + { + "label": "Schedules and event triggers", + "cells": [ + "Unlimited", + "Unlimited", + "Unlimited", + "Unlimited" + ] + }, + { + "label": "Agent runs per month", + "cells": [ + "5,000", + "10,000, then $5 per 10,000", + "10,000, then $5 per 10,000", + "Custom" + ] + }, + { + "label": "Evaluations per month", + "cells": [ + "20", + "Unlimited", + "Unlimited", + "Unlimited" + ] + }, + { + "label": "Trace data retention", + "cells": [ + "1 week", + "1 month", + "3 months", + "Custom" + ] + } + ] + }, + { + "title": "Build and use agents", + "rows": [ + { + "label": "Build and use agents through chat", + "cells": [ + true, + true, + true, + true + ] + }, + { + "label": "Instructions and reusable skills", + "cells": [ + true, + true, + true, + true + ] + }, + { + "label": "Tools and integrations", + "cells": [ + true, + true, + true, + true + ] + }, + { + "label": "MCP servers", + "cells": [ + true, + true, + true, + true + ] + }, + { + "label": "Model and harness choice", + "cells": [ + true, + true, + true, + true + ] + }, + { + "label": "Files and persistent context", + "cells": [ + true, + true, + true, + true + ] + }, + { + "label": "Shared agents and conversations", + "cells": [ + true, + true, + true, + true + ] + }, + { + "label": "Granular tool permissions", + "cells": [ + true, + true, + true, + true + ] + } + ] + }, + { + "title": "Run and automate", + "rows": [ + { + "label": "Interactive agent runs", + "cells": [ + true, + true, + true, + true + ] + }, + { + "label": "Scheduled runs", + "cells": [ + true, + true, + true, + true + ] + }, + { + "label": "Event-triggered runs", + "cells": [ + true, + true, + true, + true + ] + }, + { + "label": "Unlimited schedules and triggers", + "cells": [ + true, + true, + true, + true + ] + } + ] + }, + { + "title": "Observe and improve", + "rows": [ + { + "label": "Trace for every agent run", + "cells": [ + true, + true, + true, + true + ] + }, + { + "label": "Agent and prompt version history", + "cells": [ + true, + true, + true, + true + ] + }, + { + "label": "Playground and side-by-side comparison", + "cells": [ + true, + true, + true, + true + ] + }, + { + "label": "Evaluations and LLM-as-a-judge", + "cells": [ + true, + true, + true, + true + ] + }, + { + "label": "Token usage and estimated model costs", + "cells": [ + true, + true, + true, + true + ] + }, + { + "label": "Execution logs and errors", + "cells": [ + true, + true, + true, + true + ] + } + ] + }, + { + "title": "Collaboration, security, and compliance", + "rows": [ + { + "label": "Shared agents and conversations", + "cells": [ + true, + true, + true, + true + ] + }, + { + "label": "Team roles", + "cells": [ + false, + false, + true, + true + ] + }, + { + "label": "Role-based access control", + "cells": [ + false, + false, + true, + true + ] + }, + { + "label": "SSO", + "cells": [ + false, + false, + true, + true + ] + }, + { + "label": "SOC 2 Type II report", + "cells": [ + false, + false, + true, + true + ] + }, + { + "label": "Audit logs", + "cells": [ + false, + false, + false, + true + ] + }, + { + "label": "Custom domains", + "cells": [ + false, + false, + false, + true + ] + } + ] + }, + { + "title": "Support", + "rows": [ + { + "label": "GitHub Issues", + "cells": [ + true, + true, + true, + true + ] + }, + { + "label": "Priority support", + "cells": [ + false, + false, + true, + true + ] + }, + { + "label": "Private Slack Connect channel", + "cells": [ + false, + false, + true, + true + ] + }, + { + "label": "Dedicated support", + "cells": [ + false, + false, + false, + true + ] + }, + { + "label": "Custom service-level agreement", + "cells": [ + false, + false, + false, + true + ] + } + ] + } + ] + }, + "selfHosted": { + "title": "Compare self-hosted plans", + "columns": [ + { + "name": "Open source", + "price": "Free" + }, + { + "name": "Enterprise", + "price": "Custom" + } + ], + "groups": [ + { + "title": "Usage and core product", + "rows": [ + { + "label": "Team members", + "cells": [ + "Unlimited", + "Unlimited" + ] + }, + { + "label": "Projects", + "cells": [ + "Unlimited", + "Unlimited" + ] + }, + { + "label": "Agents and workflows", + "cells": [ + "Unlimited", + "Unlimited" + ] + }, + { + "label": "Schedules and event triggers", + "cells": [ + "Unlimited", + "Unlimited" + ] + }, + { + "label": "Agent runs", + "cells": [ + "Unlimited", + "Unlimited" + ] + }, + { + "label": "Evaluations", + "cells": [ + "Unlimited", + "Unlimited" + ] + }, + { + "label": "Trace data retention", + "cells": [ + "Self-managed", + "Self-managed" + ] + }, + { + "label": "Model usage", + "cells": [ + "Bring your own", + "Bring your own" + ] + } + ] + }, + { + "title": "Build and use agents", + "rows": [ + { + "label": "Build and use agents through chat", + "cells": [ + true, + true + ] + }, + { + "label": "Instructions and reusable skills", + "cells": [ + true, + true + ] + }, + { + "label": "Tools and integrations", + "cells": [ + true, + true + ] + }, + { + "label": "MCP servers", + "cells": [ + true, + true + ] + }, + { + "label": "Model and harness choice", + "cells": [ + true, + true + ] + }, + { + "label": "Files and persistent context", + "cells": [ + true, + true + ] + }, + { + "label": "Shared agents and conversations", + "cells": [ + true, + true + ] + }, + { + "label": "Granular tool permissions", + "cells": [ + true, + true + ] + } + ] + }, + { + "title": "Run, observe, and improve", + "rows": [ + { + "label": "Interactive, scheduled, and event-triggered runs", + "cells": [ + true, + true + ] + }, + { + "label": "Trace for every agent run", + "cells": [ + true, + true + ] + }, + { + "label": "Agent and prompt version history", + "cells": [ + true, + true + ] + }, + { + "label": "Playground and side-by-side comparison", + "cells": [ + true, + true + ] + }, + { + "label": "Evaluations and LLM-as-a-judge", + "cells": [ + true, + true + ] + }, + { + "label": "Token usage and estimated model costs", + "cells": [ + true, + true + ] + }, + { + "label": "Execution logs and errors", + "cells": [ + true, + true + ] + } + ] + }, + { + "title": "Collaboration and security", + "rows": [ + { + "label": "Role-based access control", + "cells": [ + true, + true + ] + }, + { + "label": "SSO", + "cells": [ + true, + true + ] + }, + { + "label": "Audit logs", + "cells": [ + false, + true + ] + }, + { + "label": "Custom domains", + "cells": [ + false, + true + ] + } + ] + }, + { + "title": "Deployment and support", + "rows": [ + { + "label": "Docker Compose", + "cells": [ + true, + true + ] + }, + { + "label": "Deployment and upgrade support", + "cells": [ + "Community", + "Included" + ] + }, + { + "label": "GitHub Issues", + "cells": [ + true, + true + ] + }, + { + "label": "Priority support", + "cells": [ + false, + true + ] + }, + { + "label": "Private Slack Connect channel", + "cells": [ + false, + true + ] + }, + { + "label": "Dedicated support", + "cells": [ + false, + true + ] + }, + { + "label": "Custom service-level agreement", + "cells": [ + false, + true + ] + } + ] + } + ] + } + }, + "faqs": [ + { + "question": "What counts as an agent run?", + "answer": "An agent run is any tracked execution that creates a trace. Sending a message to an agent, creating an annotation, or starting an agent from a schedule or event each creates one agent run and one trace. Each new message, annotation, or trigger is a separate run." + }, + { + "question": "Is an agent run the same as a trace?", + "answer": "An agent run is the customer-facing usage unit. A trace is the technical record Agenta creates for that run so you can inspect its model calls, tool calls, timing, outputs, and errors. Every agent run creates one trace." + }, + { + "question": "Are users, projects, agents, and workflows really unlimited?", + "answer": "Yes, on every paid Cloud plan and both self-hosted plans. Invite as many team members as you need and create as many projects, agents, workflows, schedules, and triggers as you want. The free Cloud Hobby plan includes two team members. You pay based on agent runs, not seats, projects, agents, steps, or workflows." + }, + { + "question": "Are AI model costs included?", + "answer": "No. Connect your own model-provider credentials and pay the provider directly." + }, + { + "question": "Which model providers can I use?", + "answer": "Connect OpenAI, Anthropic, Gemini, Mistral, Groq, MiniMax, Together AI, OpenRouter, Azure OpenAI, AWS Bedrock, or Google Vertex AI. You can also connect an OpenAI-compatible endpoint." + }, + { + "question": "What happens when I reach my usage limit?", + "answer": "Hobby stops running agents after 5,000 agent runs in a monthly billing period. Upgrade to Pro to continue. Pro and Business include 10,000 agent runs per month. Additional runs are billed automatically at $5 per 10,000, and your agents continue running without interruption." + }, + { + "question": "Do unused agent runs roll over?", + "answer": "No. Included agent runs reset at the start of each monthly billing period." + }, + { + "question": "Where can I monitor usage?", + "answer": "Open Settings, then Billing to see your current agent-run usage and plan allowance." + }, + { + "question": "How can I estimate how many agent runs I need?", + "answer": "Count each message sent to an agent, each annotation, and each invocation started by a schedule or event. For example, one daily scheduled agent uses about 30 or 31 runs per month, one agent triggered every hour uses about 720 to 744 runs per month, and a chat with 20 user messages uses 20 runs." + }, + { + "question": "What does trace data retention cover?", + "answer": "Trace data retention controls how long Agenta Cloud keeps technical execution records, including model calls, tool calls, timing, outputs, errors, evaluations, and annotations. Hobby retains trace data for one week, Pro for one month, and Business for three months. Enterprise retention is configurable. Trace data retention does not describe the retention of chats, project files, or persistent agent context. With self-hosted Agenta, you manage trace storage and retention in your own infrastructure." + }, + { + "question": "What is the difference between Agenta Cloud and self-hosted Agenta?", + "answer": "Agenta Cloud is operated by us. We run the application, storage, agent runner, upgrades, and backups. With self-hosted Agenta, you deploy and operate those components in your own infrastructure, connect your own model and integration accounts, and control where your data is stored." + }, + { + "question": "What is included in the free open-source edition?", + "answer": "The open-source edition includes the core agent workspace: chat, projects, agent configuration, instructions, skills, tools, integrations, MCP servers, files, schedules, event triggers, tracing, evaluations, version history, role-based access control, and SSO. You can create unlimited users, projects, agents, and workflows on your own infrastructure." + }, + { + "question": "Why pay for Enterprise if Agenta is open source?", + "answer": "Enterprise adds audit logs, custom domains, deployment and upgrade support, priority support, a private Slack Connect channel, dedicated support, and a custom service-level agreement. The open-source edition remains free for teams that can operate it themselves and do not need those commercial features or services." + }, + { + "question": "Can I switch plans or cancel at any time?", + "answer": "Yes. Upgrade, downgrade, or cancel from Settings, then Billing." + }, + { + "question": "Do you offer discounts for startups, students, or researchers?", + "answer": "Yes. Early-stage startups, students, and researchers can email team@agenta.ai to ask about discounted or free access. Include what you are building and how you plan to use Agenta." + }, + { + "question": "Does Agenta offer a free trial?", + "answer": "Agenta does not use a time-limited free trial. Start with the free Hobby plan on Agenta Cloud or deploy the free open-source edition on your own infrastructure." + } + ] +} diff --git a/docs/design/gtm/readme-rewrite-draft.md b/docs/design/gtm/readme-rewrite-draft.md new file mode 100644 index 0000000000..4abafe9b88 --- /dev/null +++ b/docs/design/gtm/readme-rewrite-draft.md @@ -0,0 +1,492 @@ +

+ + + + + Agenta logo + + +

+ +

The open-source workspace for building agents and AI automations

+ +

+ Build agents by chatting with them. Share them with your team or run them in the background. +

+ +

+ Try Agenta Cloud free • + Self-host Agenta • + Documentation +

+ +

+ MIT license. + + Doc + + + PRs welcome + + Contributors + + PyPI - Downloads + + Last Commit +

+ +

+ + + + + + + + + +

+ +--- + +## What is Agenta? + +Agenta gives your team one place to build, run, and improve agents and AI automations. + +You create an agent by chatting with it and explaining the work. You test it on real tasks, correct it, and improve its instructions, skills, tools, and context over time. Once it works, your team can keep using it in chat or run it in the background on a schedule or event. + +The people who understand the work should be able to build the agents that do it. They should not need to wait for another internal tool or translate their work into a rigid workflow first. + +## Why we built Agenta + +We built Agenta for ourselves. + +We were already using Claude Code and other coding agents for work outside engineering. We used them for go-to-market research, positioning, messaging, and recurring operations. The agents were powerful, but the setup lived in an integrated development environment or terminal. It was difficult to share with the team, keep context over time, run in the background, and understand what happened after a run. + +We wanted: + +- One workspace where each agent could keep files and build durable context, such as a go-to-market wiki. +- Agents that the whole team could use, not only the person who created them. +- The freedom to choose and switch models and agent harnesses. A harness is the coding agent runtime, such as Claude Code or Pi, that plans and uses tools. +- Support for open formats and standards such as `AGENTS.md`, skills, tools, and Model Context Protocol (MCP) servers. +- Versions, traces, token usage, and estimated costs so we could understand runs and improve agents over time. +- Interactive agents that could become background automations without rebuilding the task as a node graph. + +So we built Agenta. We have been using it while building it, first for go-to-market and engineering work. It is still early, and we want to build the next set of agents and templates with the community. + +## What sets Agenta apart? + +### Build agents by working with them + +You do not need to define the entire automation before you start. Give the agent a real task, see what it does, and improve it through conversation. + +When the agent works, keep using it interactively, share it with teammates, or add a trigger so it runs in the background. + +### Choose your harness and model + +Agenta separates the agent from the model and harness that run it. This gives you control and reduces dependence on one vendor. + +Today, the launch build supports Pi and Claude Code harnesses. It supports selected hosted model providers, including OpenRouter. More harnesses and model paths are planned. + +### Use an existing subscription when self-hosting + +For supported local self-hosted configurations, you can use an existing Claude or ChatGPT/Codex login instead of paying for every run through a separate API key. + +This requires mounting the local harness configuration into the runner and logging in on the host. Subscription state stays on the local runner and does not run on Daytona. + +### Keep agents portable and inspectable + +An agent in Agenta is made from components you can read and change: + +```text +AGENTS.md + Skills + Tools and MCP servers + Triggers + Workspace files +``` + +These components describe what the agent knows, what it can do, when it runs, and which context it keeps. They are not hidden inside a proprietary visual workflow. + +Open formats make the agent easier to inspect and reuse. Exact portability still depends on what each harness supports. For example, MCP support can vary by harness and transport. + +### Give every agent a workspace + +An agent can access folders and files in its workspace. This gives it durable working context instead of making every conversation start from zero. + +For example, our go-to-market agent works with a wiki that contains competitor research, positioning, messaging, experiments, and founder decisions. It can update that context as the work changes and use it in later tasks. + +A workspace can contain: + +- Research and source material +- Company and product context +- Instructions and reusable skills +- Drafts and completed work +- A living wiki maintained with the agent + +### Version agents and inspect every run + +Agents are non-deterministic. The same instruction can produce different actions and results. Agenta records the information you need to understand those runs and improve the agent. + +- **Tracing:** inspect agent, model, and tool calls. +- **Versioning:** keep an immutable history of instructions, skills, tools, and configuration. +- **Usage:** inspect tokens for each run. +- **Estimated cost:** see list-price estimates when the model is recognized. +- **Evaluations:** compare behavior as you change an agent. + +These controls help you build agents that become more reliable over time. They do not guarantee that every agent run will succeed. + +### Share agents with your team + +You can share agents so teammates use the same instructions, skills, tools, and workspace context. This turns a setup that lived in one person's terminal into a shared system for the team. + +Role-based access control (RBAC), single sign-on, and related organization controls are enterprise features. + +## What is an agent in Agenta? + +Your agent combines six parts: + +1. **Instructions:** an `AGENTS.md` file that describes its role and how it should work. +2. **Skills:** reusable knowledge and procedures the agent can load when needed. +3. **Tools:** actions the agent can take in Agenta or external services. +4. **MCP servers:** external tools exposed through the Model Context Protocol. +5. **Triggers:** schedules or connected application events that start the agent. +6. **Workspace files:** persistent context and work products the agent can read and update. + +The model, harness, and runtime remain separate choices. + +Your agent can work in four ways: + +- **Interactive:** you work with it directly in chat. +- **Collaborative:** teammates use the same agent and shared context. +- **Composable:** another agent calls it as a subagent. +- **Background:** it runs on demand, on a schedule, or when a connected event occurs. + +## From conversation to automation + +1. **Start with a real task.** Explain the work and let the agent attempt it. +2. **Correct the result.** Add context and clarify how decisions should be made. +3. **Improve the agent.** Update instructions, skills, tools, files, model, or harness. +4. **Save the working version.** Keep the configuration that produced the useful result. +5. **Share or automate it.** Let teammates use it, or run it on a schedule or event. +6. **Inspect the runs.** Use traces, versions, token usage, costs, and evaluations to improve it. + +## What can you build with Agenta? + +Agenta is new, so this library is still thin. We would like contributions, templates, and examples from people using it on real work. We will keep this section updated as those examples prove useful. + +### Go-to-market agent + +Give the agent access to a workspace containing competitors, positioning, messaging, customer research, experiments, and decisions. Use it to research markets, draft launch assets, maintain the wiki, and prepare recurring updates. + +This is one of the agents we use ourselves. + +### Signup research and outreach agent + +When someone signs up, start an agent through a connected application event. The agent researches the company, compares it with your customer profile, and prepares an outreach recommendation. + +### Engineering agent + +Give the agent repository context, tools, and engineering procedures. Use it to investigate issues, prepare implementation work, review changes, or run recurring repository checks. + +We have also been automating parts of our engineering work with Agenta. + +### Research agent + +Ask an agent to gather evidence, delegate parts of the research, preserve sources, and write a report into its workspace. Later runs can build on the same research instead of starting again. + +### Support agent + +Connect the agent to support context and product documentation. It can investigate a question, prepare a response, and preserve useful findings for the team. + +## Templates and tutorials + +We plan to ship each launch tutorial with the agent template it builds. The tutorial proves that the template works, and the template gives the reader a working starting point. + +Initial candidates: + +- Build a go-to-market research agent with a persistent wiki +- Research new signups and prepare outreach recommendations +- Turn a recurring engineering check into a scheduled agent +- Build a support investigation agent with shared team context + +## Not another workflow builder + +A workflow runs the graph you define. That works well when every step is known in advance and should run in the same order. + +An agent can decide what to do next. It can use tools, write code when needed, inspect the result, and change its approach. You discover the useful process by working with the agent instead of drawing every branch before the first run. + +If you know every step in advance and need deterministic execution, use a workflow tool. If the work needs judgment, research, iteration, or changing context, an agent may fit better. + +Agenta can still show you what the agent did through traces and visualizations. The visualization explains a run. It does not force you to design the work as a node graph first. + +## How does Agenta compare? + +### n8n, Activepieces, Gumloop, and Zapier + +These products center on workflows and application integrations. You usually define steps, nodes, or recipes that connect services. + +Agenta centers on agents that you build through conversation and real work. The agent can plan, use tools, write code, maintain workspace context, and adapt its approach. Agenta also versions the agent and traces each run. + +Use a workflow product when the process is known and deterministic. Consider Agenta when the task needs judgment and you want the same agent to work interactively and in the background. + +### Claude Cowork + +Claude Cowork provides a workspace experience built around Claude. Agenta's intended difference is control and portability: open-source deployment, multiple supported harnesses and models, inspectable agent components, team sharing, triggers, versioning, and tracing. + +### Claude Code, Codex, Pi, and OpenCode + +These are coding-agent tools or harnesses, not direct replacements for Agenta. They provide the execution layer that plans and uses tools. + +Agenta adds the shared workspace around supported harnesses: agent configuration, files, team access, triggers, versions, traces, evaluations, and reusable templates. The goal is to turn the power people already use individually into agents the whole team can use. + +## Tools, MCP servers, and integrations + +Agents can use harness-native tools, Agenta platform tools, configured MCP servers, and connected application integrations. + +Agenta can connect to services such as GitHub and Slack through its integration provider. Connected application events can also trigger an agent. + +## What's supported today + +**Harnesses** + +- [x] Pi +- [x] Claude Code +- [ ] Codex as a harness +- [ ] OpenCode +- [ ] More harnesses + +**Models and authentication** + +- [x] Selected provider API keys, including OpenAI, Anthropic, Google, Mistral, Groq, MiniMax, Together AI, and OpenRouter through supported harnesses +- [x] Claude and ChatGPT/Codex subscription authentication for self-hosted local runs with manual configuration +- [ ] Ollama for agent harness runs +- [ ] vLLM for agent harness runs +- [ ] Hugging Face models for agent harness runs +- [ ] Any OpenAI-compatible endpoint + +**Where agents run** + +- [x] Agenta Cloud +- [x] Self-hosted through the local runner +- [x] Daytona sandbox with your own account and snapshot +- [ ] E2B + +**How agents run** + +- [x] Interactively in chat +- [x] On a cron schedule +- [x] On connected application events through Composio +- [ ] Through a generic inbound webhook + +**Inspection and improvement** + +- [x] Tracing +- [x] Immutable version history +- [x] Token usage +- [x] Estimated model cost when recognized +- [x] Evaluations + +## See what your agents did + +Background agents can fail quietly. A run may finish while one action did nothing, used the wrong context, or cost more than expected. + +Agenta traces each run so you can inspect the agent, model, and tool calls. You can compare the run with the agent version that produced it, review token usage and estimated cost, and use that evidence to improve the next version. + +## Get started + +### Agenta Cloud + +The fastest way to try it. Free tier, no credit card. + +[**Try Agenta Cloud**](https://cloud.agenta.ai?utm_source=github&utm_medium=referral&utm_campaign=readme) + +### Self-host Agenta + +You need Docker and Docker Compose. + +1. Clone Agenta: + +```bash +git clone https://github.com/Agenta-AI/agenta && cd agenta +``` + +2. Create the environment file from the example: + +```bash +cp hosting/docker-compose/oss/env.oss.gh.example hosting/docker-compose/oss/.env.oss.gh +``` + +3. Start the services: + +```bash +docker compose -f hosting/docker-compose/oss/docker-compose.gh.yml --env-file hosting/docker-compose/oss/.env.oss.gh --profile with-web --profile with-traefik up -d +``` + +4. Open `http://localhost`. + +For remote hosts and custom ports, see the [self-hosting guide](https://agenta.ai/docs/self-host/quick-start?utm_source=github&utm_medium=referral&utm_campaign=readme) and [remote deployment guide](https://agenta.ai/docs/self-host/guides/deploy-remotely?utm_source=github&utm_medium=referral&utm_campaign=readme). + +## What's rough today + +Agenta is early. We would rather state the limits clearly than let people discover them after installation. + +- The supported harness and model matrix is still narrow. +- Subscription authentication requires manual self-hosted configuration. +- Composio integrations require the operator's own Composio key. +- User-configured MCP servers use HTTP transport and currently work with the Claude harness only. +- The default local runner executes inside the runner container and is not a tenant isolation boundary. Daytona provides a separate sandbox option. +- Templates and tutorials are still limited. + +## What comes next + +- More harnesses and model options +- Tested templates for go-to-market, engineering, support, and operations +- More tools, MCP servers, and application integrations +- Better background operation +- Easier team sharing and access control +- Better ways to improve agents from traces and evaluations + +Want a specific harness, model, integration, or template? [Open a GitHub Discussion](https://github.com/Agenta-AI/agenta/discussions) or contribute it. + +## Help us build the template library + +We have just built this version of Agenta, and the template library is still thin. We would like to learn from the agents you are building and the work you want to automate. + +You can help by: + +- Sharing a real agent and the job it performs +- Contributing a template or tutorial +- Reporting where installation or onboarding failed +- Requesting an integration or trigger +- Showing us a recurring task you want to automate + +## Community + +- [Documentation](https://agenta.ai/docs/?utm_source=github&utm_medium=referral&utm_campaign=readme): guides and API reference +- [Changelog](https://agenta.ai/docs/changelog?utm_source=github&utm_medium=referral&utm_campaign=readme): recent updates +- [Slack](https://join.slack.com/t/agenta-hq/shared_invite/zt-37pnbp5s6-mbBrPL863d_oLB61GSNFjw): ask questions and get help +- [Report a bug](https://github.com/Agenta-AI/agenta/issues) +- [Share an idea](https://github.com/Agenta-AI/agenta/discussions) +- [Contributing guide](CONTRIBUTING.md) + +## Star Agenta + +**Consider giving us a star!** It helps us grow our community and gets Agenta in front of more developers. + +

+ + Star us + +

+ +## Contributors ✨ + + +[![All Contributors](https://img.shields.io/badge/all_contributors-68-orange.svg?style=flat-square)](#contributors-) + + +Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Sameh Methnani
Sameh Methnani

💻 📖
Suad Suljovic
Suad Suljovic

💻 🎨 🧑‍🏫 👀
burtenshaw
burtenshaw

💻
Abram
Abram

💻 📖
Israel Abebe
Israel Abebe

🐛 🎨 💻
Master X
Master X

💻
corinthian
corinthian

💻 🎨
Pavle Janjusevic
Pavle Janjusevic

🚇
Kaosi Ezealigo
Kaosi Ezealigo

🐛 💻
Alberto Nunes
Alberto Nunes

🐛
Maaz Bin Khawar
Maaz Bin Khawar

💻 👀 🧑‍🏫
Nehemiah Onyekachukwu Emmanuel
Nehemiah Onyekachukwu Emmanuel

💻 💡 📖
Philip Okiokio
Philip Okiokio

📖
Abhinav Pandey
Abhinav Pandey

💻
Ramchandra Warang
Ramchandra Warang

💻 🐛
Biswarghya Biswas
Biswarghya Biswas

💻
Uddeepta Raaj Kashyap
Uddeepta Raaj Kashyap

💻
Nayeem Abdullah
Nayeem Abdullah

💻
Kang Suhyun
Kang Suhyun

💻
Yoon
Yoon

💻
Kirthi Bagrecha Jain
Kirthi Bagrecha Jain

💻
Navdeep
Navdeep

💻
Rhythm Sharma
Rhythm Sharma

💻
Osinachi Chukwujama
Osinachi Chukwujama

💻
莫尔索
莫尔索

📖
Agunbiade Adedeji
Agunbiade Adedeji

💻
Emmanuel Oloyede
Emmanuel Oloyede

💻 📖
Dhaneshwarguiyan
Dhaneshwarguiyan

💻
Priyanshu Prajapati
Priyanshu Prajapati

📖
Raviteja
Raviteja

💻
Arijit
Arijit

💻
Yachika9925
Yachika9925

📖
Aldrin
Aldrin

⚠️
seungduk.kim.2304
seungduk.kim.2304

💻
Andrei Dragomir
Andrei Dragomir

💻
diego
diego

💻
brockWith
brockWith

💻
Dennis Zelada
Dennis Zelada

💻
Romain Brucker
Romain Brucker

💻
Heon Heo
Heon Heo

💻
Drew Reisner
Drew Reisner

💻
Ikko Eltociear Ashimine
Ikko Eltociear Ashimine

📖
Vishal Vanpariya
Vishal Vanpariya

💻
Youcef Boumar
Youcef Boumar

📖
LucasTrg
LucasTrg

💻 🐛
Ashraf Chowdury
Ashraf Chowdury

🐛 💻
jp-agenta
jp-agenta

💻 🐛
Mr Unhappy
Mr Unhappy

🐛 🚇
Moreno Bonaventura
Moreno Bonaventura

🐛
Yoshiki Ozaki
Yoshiki Ozaki

🐛
ahmed-agenta
ahmed-agenta

🎨
Vahant Sharma
Vahant Sharma

📖
Muhammad Muzammil
Muhammad Muzammil

💻
Sirous Namjoo
Sirous Namjoo

📖 💡
adityadewan22-hub
adityadewan22-hub

💻
lif
lif

💻
karim kohel
karim kohel

💡 📖
Vishesh Paliwal
Vishesh Paliwal

💻
Eve
Eve

💻
Endoze
Endoze

🚇 📖
Ansh Kakkar
Ansh Kakkar

🐛
Devarsh Prajapati
Devarsh Prajapati

🐛
AxelRay
AxelRay

📦 📖
Sanket Shakya
Sanket Shakya

💻 📦 📖
unfitcoder101
unfitcoder101

🐛
Shunmuka Valsa
Shunmuka Valsa

💻 📦
Hồ Trung Nam
Hồ Trung Nam

💻
Koushik-Salammagari
Koushik-Salammagari

🐛
+ + + + + + +This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind are welcome! + +## License + +MIT. diff --git a/docs/design/gtm/signin-redesign-handoff/README.md b/docs/design/gtm/signin-redesign-handoff/README.md new file mode 100644 index 0000000000..28bfaa41ea --- /dev/null +++ b/docs/design/gtm/signin-redesign-handoff/README.md @@ -0,0 +1,103 @@ +# Handoff: Agenta sign-in / sign-up redesign + +## Overview +Redesign of the combined sign-in / sign-up screen for Agenta's new positioning ("the open-source workspace for building and running agents"). One screen serves new and returning users. The chosen direction is a conservative two-column layout: auth form left, marketing panel right. Three states are specified (first visit, returning via OAuth, returning via email code), each in light and dark. + +## About the design files +`signin-designs.html` is a **design reference created in HTML** — a self-contained canvas showing all states in both modes (open it in any browser; pan/zoom). It is a prototype of intended look and behavior, **not production code**. Recreate these designs in the target codebase's existing environment (the Agenta web app: Next.js/React) using its established patterns; do not ship this HTML. + +## Fidelity +**High-fidelity.** Colors, type, spacing, radii, and shadows are final and use the Agenta brand tokens (included under `tokens/`). Recreate pixel-perfectly. + +⚠️ **Font licensing**: GT Alpina (display serif) and PP Mondwest are trial fonts in the design workspace. License them before production. Inter loads from Google Fonts. + +## The canvas +In `signin-designs.html`, the newest work is at the top: +- **3a** — returning visit, last used = email code (light + dark) +- **2a** — first visit, final form (light + dark) +- **2b** — returning visit, last used = Google (light + dark) +- Turn 1 below (1a–1e) is exploration history; **implement 2a / 2b / 3a only.** + +## Layout (desktop, 1440×900 reference) +- Page background: light `#F6F5F3` / dark `#1E1C1D`. 12px outer gutter, 12px gap between columns. +- **Left column**: 560px fixed. Logo top-left (full lockup, 23px tall; black mark on light, white on dark), padding 28px 36px 40px. Form column is 400px, horizontally centered, vertically centered in remaining space. +- **Right column**: fills the rest. A flat panel — light: `#FFFFFF` with 1px `#E5E5E3` ring; dark: `#242220` with 1px `rgba(229,229,227,0.1)` ring. Content vertically centered, padding 96px, 26px vertical gaps. No border radius on the panel (square, per brand). + +### Right panel content (identical in all states) +1. Chip link → `https://github.com/Agenta-AI/agenta`: GitHub mark (13px) + "Open source · GitHub" + ↗ arrow. Pill (999px radius), 5px 12px padding, Inter 500 12/18 +3% tracking. Light: bg `#F6F5F3`, 1px `#F0EFED` ring, text `#676770`. Dark: bg `rgba(255,255,255,0.06)`, ring `rgba(229,229,227,0.1)`, text `rgba(255,255,255,0.64)`. +2. Headline: "Build agents that automate your work" — GT Alpina Light 300, 48/52, max-width 520px. Light `#242424` / dark `#FFFFFF`. +3. Three feature rows, 1px hairline separators (`#F0EFED` light / `rgba(229,229,227,0.1)` dark), 16px vertical padding, 14px gap, 20px line icons (1.5px stroke, currentColor), Inter 400 15/22: + - Build agents by chatting with them (chat-bubble icon) + - Connect the apps you use (grid icon) + - Run background agents on a schedule or event (clock icon) + +## Form (400px column) +Heading above form: GT Alpina 300 34/40 — "Welcome to Agenta" (first visit) / "Welcome back" (returning). First visit only: sub-line Inter 400 15/22 secondary — "Sign in or create an account." + +Form stack, 22px gaps, Inter throughout: + +1. **Data residency** + - Label row: "DATA RESIDENCY" (Inter 500 12/18, +3% tracking, uppercase) + "Learn more" link, space-between. + - Two pills, 8px gap, each flex:1, 40px tall, 8px radius, globe icon 14px + "EU" / "US" (Inter 500 14/20). + - Selected: light = `#242424` bg, white text; dark = `#FFFFFF` bg, `#242424` text. + - Unselected: light = white bg + ring shadow `0 0 0 1px rgba(72,70,68,0.08), 0 1px 3px rgba(72,70,68,0.08)`, text `#676770`; dark = transparent, 1px `rgba(229,229,227,0.1)` border, text `rgba(255,255,255,0.64)`. + - Returning: the remembered region is preselected and carries a small "Last used" tag inside the pill (10/15, 999px radius; on light-selected: bg `rgba(255,255,255,0.16)` white text; on dark-selected: bg `rgba(2,1,17,0.08)` text `#676770`). + +2. **OAuth buttons** — "Continue with Google", "Continue with GitHub". 44px tall, 8px radius, Inter 500 14/20, 16px monochrome brand glyphs, 10px gap between buttons. Light: white bg + ring shadow (above), text `#242424`. Dark: `rgba(255,255,255,0.04)` bg, 1px `rgba(229,229,227,0.1)` border, white text. + +3. **Divider** — 1px hairlines + "or" (Inter 400 12/18, faint: `#A3A19F` light / `rgba(255,255,255,0.4)` dark). + +4. **Email** — input 44px, 8px radius, placeholder "Enter your email address" (`#A3A19F` / `rgba(255,255,255,0.4)`), same surface treatment as OAuth buttons; then **Continue** button, 44px, yellow keycap: `linear-gradient(180deg,#F2F25C,#E7E712)`, `inset 0 2px 6.4px rgba(255,255,255,0.8)`, text `#242424`, Inter 500 14/20. The only yellow on screen — same in both modes. + +5. **Terms** — Inter 400 12/18 secondary: "By continuing, you agree to the Terms of Service and Privacy Policy." Links same color, underline on hover. + +## States & behavior + +### Email-first flow (unchanged from current product) +Entering an email and pressing Continue reveals the matching methods (password / one-time code / SSO) exactly as today. This redesign only reframes the screen. + +### First visit (2a) +Order: residency (EU default) → Google → GitHub → or → email → Continue → terms. + +### Returning, last used = OAuth (2b) +- Heading: "Welcome back" (no sub-line). +- The last-used provider button moves above the divider, 48px tall, stronger ring (light: `0 0 0 1px #A3A19F, 0 1px 3px rgba(72,70,68,0.1)`; dark: `rgba(255,255,255,0.08)` bg, 1px `rgba(255,255,255,0.28)` border) and an inline "Last used" tag (Inter 500 11/16 pill, light: `#F6F5F3` bg + `#F0EFED` ring; dark: `rgba(255,255,255,0.08)` + hairline ring). +- Below the divider: the other provider, email input, Continue. +- Residency pills stay in place; remembered region preselected + tagged. + +### Returning, last used = email code (3a) +- Heading: "Welcome back". +- The email block moves above the divider: input 48px with the stronger ring and the "Last used" tag positioned inside the input, right-aligned (12px from edge, vertically centered; add ~96px right padding on the input). Continue directly below. +- Google and GitHub move below the divider (normal 44px treatment). + +### Memory (client-side) +Store locally (localStorage or cookie, no server call), written after a successful auth: +- `lastAuthMethod`: `"google" | "github" | "email"` +- `lastRegion`: `"eu" | "us"` +Presence of `lastAuthMethod` switches the screen into the returning state. "Last used" tags are the only signal — no explanatory sentences anywhere. + +### Duplicate-account prevention (server-side, required) +Link accounts by verified email. If a user authenticates with a different method for an email that already has an account, sign them into the existing account (link the new method) — never create a second account, never dead-end with an error. + +## Responsive / mobile +- Right marketing panel drops entirely; left column becomes full-width, form stays ≤400px centered. +- All hit targets are ≥40px (44–48px buttons/inputs). +- In returning states the last-used method sits above the fold. + +## Design tokens +See `tokens/colors.css`, `tokens/typography.css`, `tokens/effects.css` (canonical). Key values: +- Ink: `#242424` heading, `#676770` secondary, `#A3A19F` faint +- Paper: `#FFFFFF`, `#F6F5F3` page, `#F0EFED` soft hairline, `#E5E5E3` hairline +- Dark: `#1E1C1D` page, `#242220` panel, hairline `rgba(229,229,227,0.1)`, text `#FFFFFF` / `rgba(255,255,255,0.64)` +- Yellow: `#F2F25C` → `#E7E712` (primary button gradient only) +- Radii: 8px buttons/inputs, 999px tags/chips; panels square +- Fonts: GT Alpina Light 300 (headlines), Inter (everything else) + +## Assets +- `assets/logos/` — Agenta full lockup + symbol. Naming = target background: `-light` is the black mark for light backgrounds, `-dark` the white mark for dark backgrounds, `-dark-accent` the yellow mark. +- Icons: line icons at 1.5px stroke (Lucide-compatible); Google/GitHub glyphs are monochrome, inherit text color. + +## Files +- `signin-designs.html` — self-contained design canvas (all states, light + dark) +- `tokens/*.css` — brand tokens +- `assets/logos/*` — logo set diff --git a/docs/design/gtm/signin-redesign-handoff/assets/logos/Agenta-logo-full-dark-accent.svg b/docs/design/gtm/signin-redesign-handoff/assets/logos/Agenta-logo-full-dark-accent.svg new file mode 100644 index 0000000000..369e6bdff0 --- /dev/null +++ b/docs/design/gtm/signin-redesign-handoff/assets/logos/Agenta-logo-full-dark-accent.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/docs/design/gtm/signin-redesign-handoff/assets/logos/Agenta-logo-full-dark.svg b/docs/design/gtm/signin-redesign-handoff/assets/logos/Agenta-logo-full-dark.svg new file mode 100644 index 0000000000..4ef1bdfd96 --- /dev/null +++ b/docs/design/gtm/signin-redesign-handoff/assets/logos/Agenta-logo-full-dark.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/docs/design/gtm/signin-redesign-handoff/assets/logos/Agenta-logo-full-light.svg b/docs/design/gtm/signin-redesign-handoff/assets/logos/Agenta-logo-full-light.svg new file mode 100644 index 0000000000..a0a275e2ba --- /dev/null +++ b/docs/design/gtm/signin-redesign-handoff/assets/logos/Agenta-logo-full-light.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/docs/design/gtm/signin-redesign-handoff/assets/logos/Agenta-symbol-dark-accent.svg b/docs/design/gtm/signin-redesign-handoff/assets/logos/Agenta-symbol-dark-accent.svg new file mode 100644 index 0000000000..12ea71c359 --- /dev/null +++ b/docs/design/gtm/signin-redesign-handoff/assets/logos/Agenta-symbol-dark-accent.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/docs/design/gtm/signin-redesign-handoff/assets/logos/Agenta-symbol-dark.svg b/docs/design/gtm/signin-redesign-handoff/assets/logos/Agenta-symbol-dark.svg new file mode 100644 index 0000000000..e74b80064d --- /dev/null +++ b/docs/design/gtm/signin-redesign-handoff/assets/logos/Agenta-symbol-dark.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/docs/design/gtm/signin-redesign-handoff/assets/logos/Agenta-symbol-light.svg b/docs/design/gtm/signin-redesign-handoff/assets/logos/Agenta-symbol-light.svg new file mode 100644 index 0000000000..58b30df42e --- /dev/null +++ b/docs/design/gtm/signin-redesign-handoff/assets/logos/Agenta-symbol-light.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/docs/design/gtm/signin-redesign-handoff/signin-designs.html b/docs/design/gtm/signin-redesign-handoff/signin-designs.html new file mode 100644 index 0000000000..6e10c6db3e --- /dev/null +++ b/docs/design/gtm/signin-redesign-handoff/signin-designs.html @@ -0,0 +1,385 @@ + + + + + Bundled Page + + + + +
A
+
Unpacking...
+ + + + + + + + + + + + \ No newline at end of file diff --git a/docs/design/gtm/signin-redesign-handoff/tokens/colors.css b/docs/design/gtm/signin-redesign-handoff/tokens/colors.css new file mode 100644 index 0000000000..04e37ac5c6 --- /dev/null +++ b/docs/design/gtm/signin-redesign-handoff/tokens/colors.css @@ -0,0 +1,66 @@ +/* Agenta Design System — color tokens + Values lifted 1:1 from the Figma file "Agenta - Branding and Website". */ + +:root { + /* ── Base: ink (warm black scale) ─────────────────────────── */ + --ink-900: #242424; /* primary text, dark buttons */ + --ink-700: #343434; /* dark surface mid */ + --ink-600: #676770; /* secondary text (web) */ + --ink-500: #848B8C; /* tertiary text (app) */ + --ink-450: #595F61; /* app secondary text */ + --ink-300: #A3A19F; /* disabled / faint labels */ + + /* ── Base: paper (warm white scale) ───────────────────────── */ + --paper-0: #FFFFFF; /* cards, section panels */ + --paper-50: #FCFBFA; /* app background */ + --paper-100: #F6F5F3; /* page background */ + --paper-150: #F0EFED; /* soft hairline / chip border */ + --paper-200: #E5E5E3; /* hairline border */ + + /* ── Base: brand accents ──────────────────────────────────── */ + --yellow-400: #F2F25C; /* Agenta yellow */ + --yellow-500: #E7E712; /* yellow gradient stop */ + --terracotta-500: #D97757; /* chart / data accent */ + + /* ── Base: dark section surfaces ──────────────────────────── */ + --carbon-900: #1E1C1D; /* near-black feature sections */ + --carbon-800: #242220; /* app dark button */ + --carbon-700: #3C3A38; /* dark section card fill */ + + /* ── Semantic: text ───────────────────────────────────────── */ + --text-heading: var(--ink-900); + --text-body: var(--ink-600); + --text-muted: rgba(2, 1, 17, 0.6); + --text-faint: var(--ink-300); + --text-on-dark: #FFFFFF; + --text-on-dark-muted: rgba(255, 255, 255, 0.64); + --text-on-yellow: var(--ink-900); + + /* app (Geist UI) text */ + --app-text-heading: #060402; + --app-text-body: #2A2C2D; + --app-text-secondary: #595F61; + --app-text-placeholder: #848B8C; + + /* ── Semantic: surfaces ───────────────────────────────────── */ + --surface-page: var(--paper-100); /* outer gutter of the site */ + --surface-section: var(--paper-0); /* white section panels */ + --surface-app: var(--paper-50); /* product app chrome */ + --surface-card: var(--paper-0); + --surface-dark: var(--carbon-900); + --surface-cta: var(--yellow-400); /* yellow CTA band */ + --surface-selected: #F0F0F0; /* app sidebar selected item */ + + /* ── Semantic: borders ────────────────────────────────────── */ + --border-default: var(--paper-200); + --border-soft: var(--paper-150); + --border-on-dark: rgba(229, 229, 227, 0.1); + --divider-app: #EFEFEF; + + /* ── Semantic: interactive ────────────────────────────────── */ + --accent-primary: var(--yellow-400); + --grad-btn-primary: linear-gradient(180deg, #F2F25C 0%, #E7E712 100%); /* @kind color */ + --grad-btn-dark: linear-gradient(180deg, #4D4D4D 0%, #222222 100%); /* @kind color */ + --grad-btn-outline: linear-gradient(180deg, rgba(246,245,243,0.4) 0%, rgba(229,229,227,0.4) 100%); /* @kind color */ + --grad-chip: linear-gradient(180deg, #F4F2F0 0%, #E9E5E2 100%); /* @kind color */ +} diff --git a/docs/design/gtm/signin-redesign-handoff/tokens/effects.css b/docs/design/gtm/signin-redesign-handoff/tokens/effects.css new file mode 100644 index 0000000000..d7d83423ce --- /dev/null +++ b/docs/design/gtm/signin-redesign-handoff/tokens/effects.css @@ -0,0 +1,23 @@ +/* Agenta Design System — radii, shadows, borders */ + +:root { + /* ── Corner radii ─────────────────────────────────────────── */ + --radius-sm: 8px; /* buttons, badges (rect), app frames */ + --radius-md: 10px; /* app toolbar chips, inputs, sidebar */ + --radius-lg: 12px; /* pill-tab containers */ + --radius-xl: 16px; /* large showcase frames */ + --radius-pill: 999px; /* badge pills */ + /* NB: website section panels are SQUARE (radius 0, hairline border) */ + + /* ── Shadows ──────────────────────────────────────────────── */ + /* app ring: 1px tinted ring + whisper of depth (cards, chips, inputs) */ + --shadow-ring: 0px 0px 0px 1px rgba(63, 70, 75, 0.1), 0px 1px 3px 0px rgba(63, 70, 75, 0.1); + --shadow-ring-soft: 0px 0px 0px 1px rgba(72, 70, 68, 0.08), 0px 1px 3px 0px rgba(72, 70, 68, 0.08); + /* glossy top-light inside primary buttons */ + --shadow-btn-primary: inset 0px 2px 6.4px 0px rgba(255, 255, 255, 0.8); + --shadow-btn-dark: inset 0px 2px 6.4px 0px rgba(255, 255, 255, 0.3); + --shadow-btn-outline: 0 0 0 1px var(--paper-150), inset 0px 2px 6px 0px #FFFFFF; + --shadow-badge: 0 0 0 1px var(--paper-150), inset 0px 2px 0.4px 0px #FFFFFF; + /* matted "frame" around showcase screenshots (double offset ring) */ + --shadow-frame: 0px 0px 0px 1px rgba(72, 70, 68, 0.08), 0px 1px 3px 0px rgba(72, 70, 68, 0.02), 0px 0px 0px 6px #F6F4F2, 0px 0px 0px 7px #E7E5E3; +} diff --git a/docs/design/gtm/signin-redesign-handoff/tokens/typography.css b/docs/design/gtm/signin-redesign-handoff/tokens/typography.css new file mode 100644 index 0000000000..e0d4f95cc5 --- /dev/null +++ b/docs/design/gtm/signin-redesign-handoff/tokens/typography.css @@ -0,0 +1,38 @@ +/* Agenta Design System — typography tokens + GT Alpina Light = display serif (headlines) + PP Mondwest = bitmap accent (one highlighted word, numerals) + Inter = website body + labels + Geist = product app UI + Geist Mono = code, traces, data */ + +:root { + --font-display: 'GT Alpina', Georgia, 'Times New Roman', serif; + --font-bitmap: 'PP Mondwest', 'Courier New', monospace; + --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + --font-ui: 'Geist', 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; + --font-mono: 'Geist Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace; + + /* ── Web type scale ───────────────────────────────────────── */ + /* hero display — GT Alpina Light 300 */ + --text-display-xl: 300 68px/72px var(--font-display); + /* section titles — GT Alpina Light 300 */ + --text-display-lg: 300 48px/52px var(--font-display); + /* sub-section / CTA titles */ + --text-display-md: 300 32px/38px var(--font-display); + /* card / FAQ titles — GT Alpina Regular 400 */ + --text-title: 400 20px/24px var(--font-display); + /* body copy — Inter Regular */ + --text-body-md: 400 16px/24px var(--font-sans); + --text-body-sm: 400 14px/20px var(--font-sans); + /* UI labels (buttons, nav) — Inter Medium */ + --text-label: 500 14px/20px var(--font-sans); + /* badges, eyebrows — Inter Medium, +3% tracking */ + --text-caption: 500 12px/18px var(--font-sans); + --tracking-caption: 0.03em; + + /* ── App (product UI) type scale — Geist ──────────────────── */ + --app-text-title: 500 16px/24px var(--font-ui); + --app-text-label: 500 14px/20px var(--font-ui); + --app-text-small: 500 12px/16px var(--font-ui); + --app-text-mono: 500 12px/16px var(--font-mono); +} diff --git a/docs/design/gtm/signup-redesign-brief.md b/docs/design/gtm/signup-redesign-brief.md new file mode 100644 index 0000000000..920b614a94 --- /dev/null +++ b/docs/design/gtm/signup-redesign-brief.md @@ -0,0 +1,71 @@ +# Design brief: Agenta sign-in / sign-up screen + +## What I need from you + +Redesign the sign-in / sign-up screen for Agenta. I am attaching a screenshot of the current screen. Give me **4 to 5 visually distinct directions** as full-screen desktop mockups, and for each one a short note on how it collapses on mobile. Do not converge on one safe answer; I want a real spread, from conservative to bold. For each direction, tell me in one or two sentences what it is trying to communicate and why it works. + +## What Agenta is (new positioning) + +Agenta recently pivoted. The old product was an LLMOps platform (prompt management, evaluation, observability). The current screen still reflects that. The new product is: + +> **The open-source workspace for building and running agents.** +> +> Agenta is an open-source workspace where you build specialized agents that automate and augment your work. You build agents by chatting with them: you describe the work, connect the apps they need, and improve them through feedback. You can work with your agents directly in chat and share them with your team. For recurring work, you build background agents that run on a schedule or when an event occurs. + +The audience is technical: developers, technical founders, and AI tinkerers, plus the PMs and domain experts who build agents alongside them. The tone is confident and plain-spoken, engineer to engineer. Short declarative claims, no hype adjectives, no exclamation points. + +## The current screen (see attached screenshot) + +- Two columns. Left half: the sign-in/sign-up form. Agenta logo top-left, a "Welcome to Agenta AI" headline, an outdated LLMOps tagline, then an email-first form (the user enters an email, and the matching sign-in methods appear: password, one-time code, Google/GitHub, or SSO). +- Right half (desktop only): a marketing banner with an outdated headline ("Build Robust AI Applications"), an outdated paragraph, and an illustration of the old product on a graph-paper background. +- On mobile the banner disappears and only the form shows. + +## What must keep working + +- The email-first form flow stays as it is functionally. You are redesigning the frame around it: the copy, the layout, the marketing panel, the imagery, the overall feel. The form itself just needs to remain clear, centered, and comfortable (it currently sits in a ~400px column). +- The Agenta logo appears on the screen. +- The screen serves both sign-in (returning users) and sign-up (new users) in one flow, so the copy cannot assume the visitor is new. + +Everything else is open. If a direction works better with no side banner at all, or a top-anchored layout, or something else entirely, propose it. + +## Copy to work with + +Mix and match; you can also propose tighter variants of these. Do not write new positioning claims from scratch, but trimming and recombining is welcome. + +Taglines / headlines: +- "The open-source workspace for building and running agents" +- "Build agents that automate your work" +- "Welcome to Agenta" +- "Ship agents that actually work" +- "Build agents and AI automations that work" + +Supporting lines: +- "Build agents by chatting with them. Share them with your team, connect them to the apps you use, and run them in the background." +- "Build agents that automate your work by chatting with them." +- "Describe the work. Connect your apps. Run it in the background." +- "Build reliable agents to automate your work." + +## Brand starting points (guidance, not rules) + +Agenta has a brand system in progress. Treat it as a starting point and feel free to push beyond it, but these are the current instincts: + +- Warm monochrome canvas (light `#F6F5F3` or near-black `#1E1C1D`), with **one yellow accent moment per screen**: `#F2F25C`. Terracotta `#D97757` exists as a secondary data accent. +- Display type is a literary serif (GT Alpina); one word inside a headline can be highlighted in a nerdy bitmap font (PP Mondwest) inside a soft chip. Body text is Inter. +- Marketing panels tend to be square with hairline borders; rounding is reserved for interactive elements. +- Line-style icons only, no emoji. + +If a direction calls for breaking these (for example, a fully typographic dark screen with no yellow, or a warmer illustrated feel), show it anyway and say why. + +## Directions I would like to see explored (not limited to these) + +1. **Typographic**: no illustration at all. The banner (or the whole screen) carries a large serif headline with one highlighted word, generous space, one accent moment. +2. **Product-idea illustration**: instead of a screenshot, a stylized composition of what the product does, for example small cards or frames suggesting "chat with an agent", "connected apps", "runs on a schedule". No real screenshots exist for the new product yet, so nothing that pretends to be one. +3. **Single column**: drop the side banner entirely; a centered form with one strong line above it, and the brand carried by type, spacing, and background texture. +4. **Dark vs light**: at least one direction in the dark treatment and one in the light, since the new landing page leans dark and the product app is light. +5. **Wildcard**: one direction that ignores my assumptions and takes its own angle on "an open-source workspace where agents do your work". + +## Deliverables + +- 4 to 5 distinct full-screen desktop options. +- For each: a one-line rationale, the copy it uses, and how it degrades on mobile. +- Flag anything in a direction that would need a new asset (a font, an illustration, a photo) so I know the cost of choosing it.