Feature: prompter gold upgrade (#84)

* feat(prompter): make the assistant a RoboCo insider and fully wire launch

The Prompter's intelligence lived in two thin static prompts, so it asked
generic checklist questions and produced a flat task. The launch path was
also only half-wired: the panel called the generic task-create endpoint with
no project, bypassing the Prompter's own confirm flow.

Interview brain
- Rewrite the chat system prompt with RoboCo's org model, the task-spec
  standard, a dimensions playbook, and a reflect-back, 1-2-questions-per-turn,
  auto-stop discipline.
- Inject the live projects/products list each turn so the assistant grounds
  questions in real surfaces and resolves the target itself.
- Replace the brittle phrase-match readiness with a parsed roboco-meta control
  block (parse_readiness); the block is stripped from the visible reply and the
  turn now returns draft_ready + scale.

Structured GOLD draft
- Add first-class draft fields (objective, what_this_builds, the_work, notes)
  carried in the existing draft_data JSONB — no migration.
- Compose the GOLD markdown description deterministically from those fields
  (compose_description); the model never hand-formats the body.

Adaptive routing + wired launch
- Confirm now runs through the Prompter confirm endpoint with the human's
  project/product choice and edited structured draft.
- Single-cell targets a project and the cell team; a multi-cell feature targets
  a product and becomes a Main-PM coordination root that fans out.

Frontend
- Turn-envelope draft_ready (drop the duplicated phrase-match), structured
  draft card, confirm dialog with a project/product picker and a per-cell
  The Work editor, and the corrected priority labels (0 highest .. 3 lowest).

* fix(prompter): commit session writes so they survive across requests

Session create returned 201 but the row was never durably committed, so the
immediately-following /messages call could not find it and 404'd. The prompter
routes were the only write surface that never called db.commit() — every other
write route (tasks, a2a, groups, docs, product) commits explicitly rather than
rely on the request-teardown auto-commit, which is sensitive to middleware and
teardown ordering under the production server.

- Commit explicitly in all four prompter write routes (create session, send
  message, get/generate draft, confirm).
- Fix _get_session's NotFoundError: it passed a full sentence as resource_type,
  producing the doubled "... not found not found" message; now uses the
  (resource_type, resource_id) signature.
- Panel: when a message hits a session the server no longer has, start a fresh
  session and retry once instead of dead-ending on a stale id.

Add a regression test that gives each request its own non-committing session —
the real cross-request boundary the shared-session integration tests never
crossed. It reproduces the production 404 without the route commit and passes
with it.

* refactor(prompter): drop the "GOLD" jargon for plain wording

"GOLD" was informal shorthand for "a good/well-formed spec" that should never
have been baked into the LLM prompts, comments, and docstrings as if it were a
defined term. Replace it everywhere with plain language ("a well-formed task",
"a complete task spec", "the markdown description", "structured spec fields").
No behaviour change.

* feat(intake): add the intake interviewer agent role (static definition)

Phase 1 of the intake-agent feature: a new first-class `prompter` role — the
intake interviewer the CEO chats with to draft a task. This commit defines the
role across every foundation layer (no runtime yet); spawning + the live
session come next.

- identity: Role.PROMPTER, RoleLevel.INTAKE (lowest authority), an AGENTS row
  (intake-1) on the board team, ROLE_LEVEL entry. Deliberately NOT in
  BOARD_ROLES — it interviews, it does not review.
- lifecycle: gets i_am_idle like every agent (its only verb); no
  delivery-lifecycle intents.
- journaling: ReadTier.OWN — isolated, reads only its own journal.
- role_config: human-only manifest — note + evidence only, no say/dm/notify/
  channels; allows_subagent=True (research), allows_write=False.
- agents_config derives it automatically and correctly excludes it from
  TASK_CREATOR_ROLES (it drafts, it never creates tasks).
- seed presentation ("Intake"); regenerated lifecycle artifacts.
- role system prompt: read the code first, single-CEO awareness, propose
  rather than interrogate — written against the failures we saw.
- docs: roster count 19 -> 20, org charts, verb-surface table, usage roster.

All foundation drift checks pass; role/manifest/permission tests green.

* feat(intake): migrate agentrole enum to add 'prompter'

ALTER TYPE agentrole ADD VALUE IF NOT EXISTS 'prompter' so the intake agent
row seeds/spawns against a migrated production DB. Forward-only (postgres
can't drop enum values), guarded for offline mode — matches migration 012.

* style(intake): ruff format the role additions

* fix(intake): unguard the agentrole migration so it renders offline

The enum-migration-parity test renders 'alembic upgrade head --sql' (offline)
and greps for ALTER TYPE ... ADD VALUE. The is_offline_mode() guard skipped
emitting it, so the parity check couldn't see 'prompter'. Drop the guard —
PG16 permits ADD VALUE in a transaction, same as migration 020's backfill.

* feat(intake): the live-session driver (Claude Agent SDK loop)

Phase 2 begins. The intake agent isn't a one-shot `claude -p`; it's a live
Claude Code session the human chats with. This driver is the container's loop:
open one long-lived claude-agent-sdk ClaudeSDKClient, then per human message
run a turn (query + receive_response) and stream its events out, keeping
conversation context in-process — verified against the real SDK (v0.2.94).

- StreamChunk + normalize(): map SDK messages (StreamEvent text deltas,
  AssistantMessage text/thinking/tool_use blocks, ResultMessage→session_id) to
  panel-facing chunks. Duck-typed, so it works on real SDK objects and on test
  fakes alike — SDK-free, fully unit-tested.
- IntakeDriver.run(): the loop, with injected session/source/sink seams; a turn
  failure surfaces as an error chunk without killing the session.
- SdkIntakeSession + build_intake_options: the only SDK-coupled code (lazy
  import; needs the live claude binary, so excluded from coverage).
- Add claude-agent-sdk dependency + mypy ignore-missing-stubs.

Relay, panel SSE, and the persistent on-demand spawn are the next steps.

* Updated uv.lock

* feat(intake): the panel<->agent live bridge (registry, routes, entrypoint, image)

Wires the live intake chat end to end (Phase 2 integration layer):

- prompter_live.py: the orchestrator-side per-session registry — open/close,
  push (agent->panel), stream (SSE drain), deliver (panel->container). In-process
  (the orchestrator is single-process). 7 unit tests.
- routes/prompter_live.py: GET /live/{id}/stream (SSE), POST /live/{id}/messages
  (deliver), POST /live/{id}/events (relay in); registered under /api/prompter.
  5 integration tests.
- agent_sdk/intake_main.py: the container entrypoint — a POST /turn receiver
  (the driver's MessageSource) + a relay-poster EventSink + the ClaudeSDKClient
  session, run concurrently. 4 unit tests on the wiring helpers.
- docker/agent-prompter.Dockerfile: FROM base, ENTRYPOINT = the driver (not the
  one-shot `claude` the other agents use).

Remaining for Phase 2: the orchestrator persistent-spawn path (scope->workspace
clone, CMD = driver, registry.open on spawn, reap-on-confirm) — the deploy-side
piece, best finalized against a buildable image.

* feat(intake): orchestrator persistent spawn + start/stop for the live chat

Add the task-free spawn path for the intake (prompter) agent: one fixed
intake-1 container running the Agent-SDK driver (image ENTRYPOINT, not
claude -p), one live session at a time.

- spawn_intake_session clones the scope's repo(s) via WorkspaceService
  (project -> one; product -> each distinct project, primary first),
  composes the intake-1 prompt, resolves the model, and builds docker run
  via _build_intake_run_cmd: no settings/hook mount (driver owns 9000),
  no MCP config, no -w; registers the live relay and best-effort delivers
  the opening message once the receiver is up.
- reap_intake_session closes the relay and stops the container.
- Routes: POST /live/start (project XOR product) and POST /live/{id}/stop.
- ROLE_MODEL_MAP[prompter]=opus; intake-1 -> roboco-agent-prompter image map.
- Replace the budget-sweep try/except/continue with _fetch_budget_status,
  which logs the swallow at debug instead of silently dropping it.

25 new tests; docker + the clone are mocked. End-to-end container spawn is
pending a built image and the stack.

* feat(intake): wire /prompter to the live agent — scope form + SSE chat

Replace the Ollama chat loop on /prompter with the spawned-agent flow.

- IntakeForm: pick scope (project XOR product) + opening message + Start
  before the chat; the agent clones that scope and reads the real code.
- use-prompter rewritten as the live brain (lib/api/prompter-live.ts): Start
  spawns via POST /live/start, then an EventSource on /live/{id}/stream
  streams the agent working — token deltas fill the assistant bubble,
  tool_use/thinking drive a live activity line, a draft event renders the
  existing DraftProposalCard. Messages go via POST /live/{id}/messages.
- Chat UX unchanged (Keep Chatting / Review & Confirm / ConfirmDialog reused);
  reap-on-confirm and reap-on-leave call POST /live/{id}/stop.
- Drop the dead Ollama prompterApi client; trim prompter.ts to shared types.

Frontend gate green (tsc --noEmit, lint, build). The draft event + the
/live/{id}/confirm endpoint are the Phase 4 backend seam.

* feat(intake): confirm draft -> backlog task + agent draft emission

Complete the live intake vertical: the agent proposes a structured draft and
Review & Confirm turns it into a task.

- Draft emission: the prompter prompt instructs the agent to emit a fenced
  roboco-draft JSON block when the spec is ready; the driver parses it into a
  'draft' event over the existing relay -> the panel's DraftProposalCard. The
  panel strips the raw block from the chat bubble.
- Fix a double-text bug: with include_partial_messages the reply arrives as
  both StreamEvent deltas and the final AssistantMessage; the driver now takes
  text from deltas only and the AssistantMessage for thinking/tool_use/draft.
- POST /live/{id}/confirm -> confirm_live_draft, reusing a draft->task core
  extracted from confirm_draft; reaps the session on success.
- Both prompter confirm paths create at BACKLOG, not pending: backlog is the
  holding area a draft waits in until it's reviewed and promoted to pending
  (TaskService.activate). The legacy Ollama confirm was creating at pending,
  skipping that gate — fixed.
- Remove the dead 'context' bootstrap param from the Ollama session-create
  chain (schema + route + method + tests), superseded by the live scope form.
- No suppressions: replace every type:ignore/noqa across the intake surface
  with a real fix (ORM .id -> UUID(str(x)); fakes -> monkeypatch.setattr;
  lazy imports -> pyproject per-file ignore; union-attr -> recipients[0]).

Full make quality green; frontend tsc + lint green.

* build(intake): add the agent-prompter image builder to compose

The orchestrator references roboco-agent-prompter (AGENT_IMAGES + the
_ensure_agent_image dockerfile map) and docker/agent-prompter.Dockerfile
exists, but docker-compose.yml built every other agent image up front and
left this one out — so the image wasn't pre-built for a stack bring-up.

Mirror the other specialized agent-*-image builders: build from
docker/agent-prompter.Dockerfile, tag roboco-agent-prompter, depend on
agent-base-image.

* Created docker-compose.yaml for the NAS

* fix(intake): non-blocking /live/start so spawn never times out

The start POST awaited the whole spawn — workspace clone + first-time image
build + docker run — which blew past the panel's 60s HTTP timeout ('Request
timed out. The server may be busy.') and triggered a duplicate send. Found on
the 2026-06-09 NAS smoke.

- start_intake_session opens the live relay synchronously, then spawns the
  container in the background (_spawn_intake_container_guarded). The route
  returns the session id immediately; the panel opens the SSE stream right away.
- A background spawn failure is pushed onto the relay as an 'error' event and
  closes the session, so the panel shows it instead of hanging.
- spawn_intake_session stays as the synchronous variant for direct callers/tests.
- Panel shows a 'Preparing the agent…' indicator until the first event arrives.

18 intake-spawn tests green; tsc + lint green. E2E re-validates on next smoke.

* fix(intake): propose_draft MCP tool + lock the agent down

Smoke 2026-06-09 exposed two compounding problems: the agent never reliably
emitted the draft (it narrated the spec instead of typing the magic fence), and
it had inherited the CEO's entire Claude Code env — Write/Edit/Bash + Gmail/
Notion/Calendar/Drive MCP — because bypassPermissions ignored the allowlist and
the mounted ~/.claude leaked the host MCP config.

- propose_draft: build_intake_options now registers an in-process SDK MCP tool
  (create_sdk_mcp_server + @tool). The agent calls it to submit the draft; the
  driver turns that ToolUseBlock into a 'draft' event (_is_propose_draft /
  _draft_from_tool_input, tolerant of nested/flat/JSON-string input). The fenced
  roboco-draft block stays as a fallback.
- Lockdown: strict_mcp_config=True + setting_sources=[] (ignore host MCP +
  settings); permission_mode 'dontAsk' + a can_use_tool gate enforcing a hard
  allowlist (Read/Grep/Glob/Task + propose_draft) replaces bypassPermissions.
- Prompt: call propose_draft (not a fence); the draft's downstream chain is
  backlog -> Board (PO + HoM) -> CEO approve -> Main PM, and the agent's job ends
  at the draft (it never routes or hands off).

SDK API verified against the installed claude-agent-sdk. Driver detection unit-
tested; the SDK-construction is validated on the next NAS smoke (incl. that
setting_sources=[] doesn't break the mounted-~/.claude auth).

* fix(intake): panel UX cluster from the smoke (#3/#4/#6/#12)

- #3 message boundaries: a tool call now ends the current text bubble, so the
  agent's words before and after a tool render as separate messages instead of
  one merged wall (the 'two waves merged into one bubble' the CEO saw).
- #4 activity indicator: promoted from tiny grey text to a prominent primary-
  tinted pill so 'watch it work' is actually visible.
- #12 End chat: a header button (any chat state) reaps the agent and resets to
  the form, reusing startAnother (which already stops the session). Backend
  POST /live/{id}/stop already existed.
- #6 log noise: the opening-message delivery retry logs at debug, not error —
  those failures are expected until the container receiver is up.
- Also fix a latent test gap from the #1 commit: the live-route test's fake
  orchestrator now exposes start_intake_session (the route's non-blocking entry).

Frontend tsc + lint green; live-route + prompter_live tests green.

* fix(intake): render markdown in the chat bubbles (#8)

The agent emits rich markdown (### headers, **bold**, tables, lists) but the
bubble rendered raw text, so it was illegible (CEO-flagged on the smoke). Render
assistant content with react-markdown + remark-gfm (GFM tables) in a prose
container. Adds react-markdown + remark-gfm to the panel.

* feat(intake): #14 — two start routes (Board review vs straight to Main PM)

Per the CEO spec, the draft confirm now starts the task at PENDING with an
explicit assignment instead of parking it at backlog:

- route="board" (Board review & Start): assigned to the Product Owner, so the
  orchestrator dispatches the full Board review (PO + Head of Marketing) before
  the Main PM picks it up.
- route="main_pm" (Approve & Start): assigned straight to the Main PM, who
  delegates to the cells (Board review skipped).

create_task_from_draft gains status + assigned_to params (default BACKLOG, so the
legacy confirm_draft is unchanged); confirm_live_draft + the /live/{id}/confirm
request carry the route. Service tests cover both routes.

* feat(intake): #14 draft-card buttons — Board review vs Approve & Start

Three buttons on the draft card now (CEO spec): Keep chatting / Board review &
Start / Approve & Start. The two action buttons confirm directly with their
route — launchTask(route) sends route to POST /live/{id}/confirm, which starts
the task at pending assigned to the Board (PO+HoM) or straight to the Main PM.

Supersedes the ConfirmDialog review step (scope is chosen up front in the form),
so it's removed from the page flow. The ConfirmDialog component + its sub-editors
are now unused — flagged for a follow-up cleanup, left in place to avoid churn.

tsc + lint green.

* fix(intake): keep the live SSE stream bound to its relay session

The orchestrator opened the relay session twice per live chat — once on the
request path (before the start call returns) and again inside the background
container spawn. The SSE stream binds to the session's queue the moment the
panel connects, so the second open swapped in a fresh queue and stranded the
stream: the agent replied normally, but its events went to the new queue while
the panel kept reading the old one, so the chat looked frozen on "Preparing…".

The second open was always redundant (the relay is opened by the caller before
the spawn). Remove it, and make open() idempotent so a live session is never
replaced out from under a stream that is already connected to it.

* fix(intake): draft-card launch buttons silently did nothing

The launch path required a `description` field, but the prompter draft schema
intentionally has none — it sends `objective` + the structured spec and the
backend composes the description (compose_description). `editableDraft.description`
was therefore undefined, so `description.trim()` inside launch validation threw a
TypeError that propagated out of the button's onClick. Clicking "Board review &
Start" / "Approve & Start" did nothing, with no feedback — the wall blocking the
whole confirm → task → reap flow.

- Map a proposed draft's description from `objective` as a fallback.
- Make launch validation null-safe.
- Replace the silent early-return with a toast that names what's missing, so a
  blocked launch is never a dead, feedback-less button again.

* fix(intake): steer the agent to ask inline, not via AskUserQuestion

The intake's job is to ask clarifying questions, so it reached for the
AskUserQuestion tool — which isn't wired to the live chat panel and isn't in its
allowlist. The bare deny left it to stumble ("let me clarify… — no worries, let
me just lay it out") and waste a visible turn.

- Prompt: spell out that it asks by writing in the chat (the human reads every
  message live) and that no question/prompt tool is available to it.
- Gate: give AskUserQuestion a specific deny message that nudges it to ask inline,
  so even a reflex attempt degrades gracefully.

Also refresh the now-stale "what happens after propose_draft" section: the draft
card has three choices (Keep chatting / Board review & Start / Approve & Start)
and produces a pending task — not the old two-button "backlog" description.

* feat(intake): copy buttons on agent messages and the draft card

The CEO asked for a way to save the agent's plan/spec elsewhere "just in case" —
a cheap manual backstop until refresh-durability lands.

- New CopyButton: async Clipboard API when available, plus a legacy
  textarea+execCommand fallback. The fallback is load-bearing — the panel is
  served over plain http on a LAN IP, where navigator.clipboard is absent
  (clipboard needs a secure context), so the modern API alone would never copy.
- Copy button under each assistant message (copies its text).
- Copy button on the draft card (copies the full spec as markdown: title,
  objective, what-this-builds, the-work per cell, notes, success criteria).

* feat(intake): unbuffer logs + log each turn so the container isn't a black box

Debugging the intake smoke was painful for two reasons: (a) the orchestrator
block-buffered stdout, so `docker logs` lagged minutes behind reality, and (b)
the intake container logged only "session opened" then went silent for the whole
conversation (the chat streams to the relay, not stdout).

- Set PYTHONUNBUFFERED=1 on the orchestrator and agent-base images so structured
  logs reach `docker logs` in real time instead of in large delayed chunks.
- Log each intake turn: "turn received" (with char count) and "turn streamed"
  (chunk count + whether a draft was emitted), so the container logs show the
  conversation's shape at a glance.

* chore(intake): remove the dead ConfirmDialog draft editor

The three-button draft card (Keep chatting / Board review & Start / Approve &
Start) replaced the old review-modal confirm flow, leaving ConfirmDialog and its
sub-editors (StringListEditor, TheWorkEditor) referenced by nothing but the
barrel export. Remove the three files and the export — typecheck + lint confirm
no remaining references.

* fix(intake): coerce bad draft enums on confirm instead of hard-failing

The intake agent is an LLM and will emit off-enum values — e.g. task_type="feature",
which is not a valid TaskType (code/documentation/research/planning/design/
administrative). `_coerce_draft_enums` called `TaskType(value)` directly, which
raised, and the confirm 400'd with "Draft has invalid or missing required fields:
'feature' is not a valid TaskType". That forced the agent to discover the valid
values and self-correct in-chat — unacceptable: clicking "Approve & Start" must
never blow up on a cosmetic enum guess.

Coerce each enum to a sane default on invalid/missing (task_type→code,
nature→technical, complexity→medium); team falls back to the first valid cell in
the_work, then backend. `_lead_cell_team` now skips invalid cell names too. The
confirm/launch action no longer hard-fails on an enum the model got wrong.

* fix(intake): draft card no longer renders above the user's latest message

attachDraft fell back to "the last assistant message anywhere" when the current
turn had no streamed text yet (propose_draft called first). That last message was
often the PREVIOUS turn's — sitting above the user's "Yes, propose it" — so the
draft card rendered above the user's message. Attach only to the current turn's
streaming message; otherwise append a fresh assistant message so the card always
lands at the bottom of the thread.

* test(intake): guard draft enum coercion + invalid-cell skipping

Regression tests for the confirm-time enum coercion: an off-enum task_type
("feature") / nature / complexity coerce to code/technical/medium instead of
raising, and _lead_cell_team skips invalid cell names. Locks in that a bad enum
guess from the agent can never 400 the launch again.

* fix(intake): stop the agent fumbling through Claude Code meta-tools

In smoke it reflexively probed CC built-ins before reaching propose_draft —
plan mode + ExitPlanMode (it announced a written plan and waited instead of
emitting the draft), ToolSearch, Write — each correctly denied by the lockdown
but stumbly, and it only proposed after explicit CEO nudges.

- Gate: ExitPlanMode now gets a specific deny nudge ("you don't use plan mode;
  call propose_draft"), and the generic deny names the actual toolset instead
  of a bare "not available", so any probe degrades into guidance.
- Prompt: forbid plan mode/ExitPlanMode/ToolSearch explicitly and spell out
  "you do not plan and wait — call propose_draft directly when the spec is
  ready," plus an anti-pattern bullet.

* feat(intake): make the container logs transparent mid-turn

`docker logs` on the intake container was a black box: only turn start/end, while
the agent read the codebase and spawned 20+ subagents invisibly (the conversation
streams to the relay, not stdout), and the benign 3x ~/.claude.json warning was
the only thing visible.

- Driver logs each tool call mid-turn ("Intake tool use" with the tool name) and
  the draft emission, plus a tools count in the turn-streamed summary. Text deltas
  stay unlogged (they'd spam). Now the logs show the turn's real shape.
- Pre-create ~/.claude.json ({}) at container boot so the CLI's "config not found"
  warning (printed 3x, self-healed anyway) stops drowning the real logs.

* fix(intake): render markdown in user messages + scope copy to code blocks

Two display fixes from the smoke:
- User messages collapsed newlines (plain {content} in a div) and rendered no
  markdown — a "1.\n2.\n3." answer showed as one run-on line. Render user AND
  assistant bubbles through a shared GFM markdown body that inherits the bubble's
  text color, so lists / newlines / styling render correctly on both.
- Copy was blanketed on every assistant message; scope it to KEY parts — a copy
  button on fenced code blocks (the draft card keeps its own). Removed the
  per-message button.

* fix(intake): prevent duplicate tasks from a double-click on launch

Clicking a draft launch button twice fired two confirms and created duplicate
tasks. Add a synchronous re-entry guard (a ref — no stale-closure window) at the
top of launchTask so a second click returns immediately, and disable + spin the
draft-card buttons while a launch is in flight so it's visually clear it's working.

* docs(how-to): lead task creation with the Task Assistant flow

Rewrite "1 · It starts with you" to walk the Prompter/Task Assistant path —
scope form, the agent reading the codebase, its grounded analysis, the draft
card, and the created task — then flow into the Board review. Replaces the old
manual task-definition form shots.

Image placeholder: images/prompter_draft_card.png (the 3-button card) is
referenced but not yet captured — TODO comment marks it for the next smoke run.
A second comment flags an optional re-capture of prompter_run_2 after the
markdown-rendering fix.

* fix(intake): restore assistant message text contrast

The markdown refactor dropped `dark:prose-invert` and made text inherit the
bubble's color, but the assistant bubble had no explicit text color — so its text
rendered near-invisible (dark-on-dark on bg-muted). Give the assistant bubble an
explicit text-foreground; the user bubble already carries text-primary-foreground,
and [&_*]:!text-inherit now resolves to a readable color on both.

* fix(intake): coerce draft priority too — confirm 500'd on priority="high"

The enum-coercion fix covered task_type/nature/complexity/team, but priority is a
non-enum int field handled by `int(draft_data.get("priority", 2))`, and the agent
guesses a word ("high") as readily as a number — so int("high") raised ValueError
and the confirm 500'd. Same class of bug, one field missed.

Add _coerce_priority: map words (urgent/high/medium/low → 0/1/2/3), clamp numbers
to 0-3, default to 2 (medium) on anything else. The launch can no longer crash on
any field the LLM guessed. + regression test.

* fix(intake): draft card shows distinct cells, not one badge per work item

the_work has one entry per work item, so a cell with several items rendered its
badge repeatedly ("Board-led across Backend Backend Backend Frontend Frontend
…"). De-dupe to distinct teams so the card reads "Board-led across Backend
Frontend" — and the "Cell:" vs "Board-led across" label keys off distinct count.

* docs(how-to): hero the teaser gif + resolve the Prompter/Task Assistant thread

- Move the 12s teaser gif to the top as the hero — it was buried between the
  "prefer video" link and the first screenshot.
- Name the connection: the Task Assistant IS the Prompter, so section 1 (using
  the tool) and the rest (RoboCo building it) read as one story — you use the
  tool the company built for itself, then watch the build.
- Re-anchor the section 1 → Board transition to follow the Prompter's own
  journey, instead of implying section 1's example task is the one reviewed next.

* Included images for how-to.md

* docs(how-to): align agent count to 20 (matches README + CLAUDE.md)

The how-to said "18 agents" with UX/UI at one dev and no Intake — stale against
the authoritative count. Bump 18→20 (prose + spelled-out eighteen→twenty), give
UX/UI 2 devs, and add the Intake line to the org tree (Intake leads section 1, so
it belongs in the tree). README + CLAUDE.md already say 20.

* ci(release): publish all RoboCo images to GHCR + Docker Hub

The release published only the orchestrator to GHCR. Build and push the full set
the stack needs — agent-base, the 8 agent images, orchestrator, and panel — to
BOTH ghcr.io/rennf93/* and docker.io/renzof93/*, at :<version> and :latest, so
consumers can pull instead of compose-building.

- agent-base builds first (the agent images build FROM roboco-agent-base, a local
  tag), then the rest; push only after every build succeeds.
- Image names mirror the docker-compose `image:` values 1:1.
- Free disk on the runner first (11 images is space-heavy).
- Needs a DOCKERHUB_TOKEN repo secret for the Docker Hub login.
- SECURITY.md updated to reference both registries.

* ci(release): use short SHA as the image tag on manual dispatch

A workflow_dispatch runs against a branch, and the branch name (e.g.
feature/prompter-gold-upgrade) was used verbatim as the image tag — but "/" is
illegal in a Docker tag, so the first build failed instantly with "invalid
reference format". Releases still tag from the release tag; manual dispatch now
always uses the short SHA, which is a valid tag.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-06-09 17:08:34 +02:00
committed by GitHub
co-authored by Renn F
parent ae65bff883
commit 9f8834155a
63 changed files with 5607 additions and 832 deletions
+85 -28
View File
@@ -6,22 +6,27 @@ on:
workflow_dispatch:
jobs:
publish-image:
name: Build & push orchestrator image to GHCR
publish-images:
name: Build & push all RoboCo images to GHCR + Docker Hub
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
env:
IMAGE: ghcr.io/rennf93/roboco
GHCR: ghcr.io/rennf93 # GitHub Container Registry namespace
DOCKERHUB: docker.io/renzof93 # Docker Hub namespace (different username)
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Free up runner disk space
run: |
# Eleven images on one runner is disk-heavy; drop preinstalled tooling
# we don't use so the builds don't run out of space.
sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/lib/android /opt/hostedtoolcache/CodeQL || true
df -h /
- name: Log in to GitHub Container Registry
uses: docker/login-action@v4
@@ -30,33 +35,85 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Derive image tags
id: tags
- name: Log in to Docker Hub
uses: docker/login-action@v4
with:
username: renzof93
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Derive version tag
id: ver
run: |
# On a published release, github.ref_name is the tag (e.g. v0.1.0);
# strip a leading "v" for the semver image tag. On workflow_dispatch
# against a branch, fall back to the short SHA.
# release → the tag (v0.1.0 → 0.1.0). Any manual dispatch runs against a
# branch whose name can contain "/" (e.g. feature/x) — not a valid image
# tag — so use the short SHA, which always is one.
if [ "${{ github.event_name }}" = "release" ]; then
RAW="${{ github.event.release.tag_name }}"
VERSION="${RAW#v}"
else
RAW="${{ github.ref_name }}"
fi
VERSION="${RAW#v}"
if [ -z "$VERSION" ] || [ "$VERSION" = "master" ]; then
VERSION="$(git rev-parse --short HEAD)"
fi
{
echo "tags<<EOF"
echo "${IMAGE}:${VERSION}"
echo "${IMAGE}:latest"
echo "EOF"
} >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "Release version: $VERSION"
- name: Build and push
uses: docker/build-push-action@v7
with:
context: .
file: docker/orchestrator.Dockerfile
push: true
tags: ${{ steps.tags.outputs.tags }}
provenance: false
- name: Build & push every RoboCo image
env:
VERSION: ${{ steps.ver.outputs.version }}
run: |
set -euo pipefail
# Tag one built image for both registries at :VERSION and :latest.
regtags() {
local name="$1"
echo "-t ${GHCR}/${name}:${VERSION} -t ${GHCR}/${name}:latest" \
"-t ${DOCKERHUB}/${name}:${VERSION} -t ${DOCKERHUB}/${name}:latest"
}
# Push one image's four tags (both registries, both labels).
pushall() {
local name="$1"
for ref in "${GHCR}/${name}" "${DOCKERHUB}/${name}"; do
docker push "${ref}:${VERSION}"
docker push "${ref}:latest"
done
}
# agent-base MUST build first: the eight agent images build
# `FROM roboco-agent-base` — a local tag that has to exist in the daemon
# before they build. Tag it locally (for the FROM) and for both registries.
echo "::group::build roboco-agent-base"
docker build -f docker/agent-base.Dockerfile \
-t roboco-agent-base $(regtags roboco-agent-base) .
echo "::endgroup::"
# Every other image → its Dockerfile. Names mirror docker-compose's
# `image:` values exactly, so compose can later pull instead of build.
declare -A IMAGES=(
[roboco-orchestrator]=docker/orchestrator.Dockerfile
[roboco-panel]=docker/panel.Dockerfile
[roboco-agent-pm]=docker/agent-pm.Dockerfile
[roboco-agent-dev-be]=docker/agent-dev-be.Dockerfile
[roboco-agent-dev-fe]=docker/agent-dev-fe.Dockerfile
[roboco-agent-qa-be]=docker/agent-qa-be.Dockerfile
[roboco-agent-qa-fe]=docker/agent-qa-fe.Dockerfile
[roboco-agent-ux]=docker/agent-ux.Dockerfile
[roboco-agent-doc]=docker/agent-doc.Dockerfile
[roboco-agent-prompter]=docker/agent-prompter.Dockerfile
)
for name in "${!IMAGES[@]}"; do
echo "::group::build ${name}"
docker build -f "${IMAGES[$name]}" $(regtags "${name}") .
echo "::endgroup::"
done
# Push only after every build succeeds, so a failure never leaves a
# half-published release. Base first, then the rest.
echo "::group::push roboco-agent-base"
pushall roboco-agent-base
echo "::endgroup::"
for name in "${!IMAGES[@]}"; do
echo "::group::push ${name}"
pushall "${name}"
echo "::endgroup::"
done
echo "Published roboco-agent-base + ${#IMAGES[@]} more images to GHCR + Docker Hub at :${VERSION} and :latest"
+6 -2
View File
@@ -15,12 +15,14 @@ keep copyright assignment language intact. See `CONTRIBUTING.md`.
## Project Overview
**RoboCo** is an AI Agentic Company - a virtual organization of 19 AI agents + 1 human CEO, designed to operate as a complete software development workforce. The system implements a structured organizational hierarchy with formal communication protocols, task management, and quality controls.
**RoboCo** is an AI Agentic Company - a virtual organization of 20 AI agents + 1 human CEO, designed to operate as a complete software development workforce. The system implements a structured organizational hierarchy with formal communication protocols, task management, and quality controls.
### Core Architecture
```
CEO (Renzo - Human)
|
+-- Intake (on-demand interviewer: chats only with the CEO to draft a task)
|
+-- Board (3 agents)
+-- Product Owner
@@ -349,9 +351,11 @@ read-only into the agent container.
| product_owner | `triage`, `escalate_to_ceo` |
| head_marketing| `triage`, `escalate_to_ceo` |
| auditor | `triage` (read-only — no `say`/`dm`) |
| prompter | (none beyond `i_am_idle` — not a delivery-lifecycle role; intake interviewer, human-only) |
Content tools (do_server) — most roles: `commit`, `note`, `say`, `dm`, `evidence`.
Auditor is restricted to `note` (scope=reflect) + `evidence`.
Auditor is restricted to `note` (scope=reflect) + `evidence`. The `prompter`
(intake) is restricted to `note` + `evidence` — human-only, no `say`/`dm`/`notify`.
### MCP servers running per agent container
+2
View File
@@ -385,6 +385,8 @@ prune:
.PHONY: clean
clean:
@find . | grep -E "(__pycache__|\.pyc|\.pyo|\.pytest_cache|\.ruff_cache|\.mypy_cache)" | xargs rm -rf
@cd panel && rm -rf node_modules/ && rm -rf .next/ && rm -rf logs/
@cd ..
# Security
.PHONY: panel-token
+4 -2
View File
@@ -1,6 +1,6 @@
# RoboCo
AI Agents Company - A virtual organization of 19 AI agents + 1 human CEO, designed to operate as a complete software development workforce.
AI Agents Company - A virtual organization of 20 AI agents + 1 human CEO, designed to operate as a complete software development workforce.
<p align="center">
<img src="docs/images/run.png" alt="RoboCo control panel: the task tree for a feature, showing Board → Main PM → Backend / Frontend / UX/UI cells → developer subtasks, with live lifecycle statuses (completed, in progress, awaiting PM review, paused) and real GitHub PRs (#59#62)." width="100%">
@@ -25,6 +25,8 @@ RoboCo implements a structured organizational hierarchy with formal communicatio
```
CEO (You, the human)
├── Intake (on-demand interviewer: chats only with you to draft a task)
└── Board (3 agents)
├── Product Owner
@@ -234,7 +236,7 @@ uv run mypy roboco/
- [x] Database ORM (SQLAlchemy async)
- [x] Task lifecycle state machine
- [x] Multi-agent workspace management
- [x] Agent prompts (19 agents)
- [x] Agent prompts (20 agents)
- [x] Messaging API
- [x] Task API with full lifecycle
- [x] Git operations API
+3 -2
View File
@@ -16,8 +16,9 @@ versioned library. Security fixes are applied to the latest release and the
| Latest release tag | :white_check_mark: |
| Older releases | :x: |
Always run the most recent image (`ghcr.io/rennf93/roboco:latest`) or build
from the latest `master`.
Always run the most recent images — from GHCR (`ghcr.io/rennf93/roboco-*`) or
Docker Hub (`renzof93/roboco-*`), tag `latest` — or build from the latest
`master`.
## Reporting a Vulnerability
@@ -0,0 +1,6 @@
# Verbs available to your role (prompter)
These are the only verbs the gateway will accept from you. Calling any
other verb will be rejected with a Decision telling you the right one.
- **i_am_idle**: Signal you have no active work. PMs auto-pause owned in_progress tasks.
+1 -1
View File
@@ -1,6 +1,6 @@
# RoboCo Agent — Base
You are an agent in **RoboCo**, an AI company with 19 AI agents + 1 human CEO. Your role-specific prompt names your verbs and your responsibilities; this file holds the rules every role obeys.
You are an agent in **RoboCo**, an AI company with 20 AI agents + 1 human CEO. Your role-specific prompt names your verbs and your responsibilities; this file holds the rules every role obeys.
## Identity
+87
View File
@@ -0,0 +1,87 @@
# Intake
## Identity
You are the **Intake interviewer**. You talk to exactly one person — the human CEO — and to no other agent. Your job: take a rough idea, read the **actual codebase** for the scope you've been given, ask a few sharp questions, and produce a well-formed task draft the CEO can launch. You do NOT write code, merge, or create tasks. You **draft** one; the human confirms it and the Board reviews it.
There is exactly one human in this company: the CEO. Every other actor is an AI agent. **Never** ask about users, accounts, access control, permissions, ownership, or multi-tenancy — those questions are meaningless here and mark you as not understanding RoboCo.
You are spawned scoped to a **project** (one repo) or a **product** (a set of repos, one per cell). Those repos are checked out in your workspace. **Read them before you ask anything.**
## How RoboCo is organized (so your drafts route correctly)
- CEO → Board (Product Owner, Head of Marketing, Auditor) → Main PM → three delivery cells: Backend, Frontend, UX/UI.
- Small, single-domain work (a bug fix, one endpoint, one component) is **one task, one cell**.
- A real feature is **board-led**: the Board sets requirements, the Main PM delegates one subtask per participating cell, and the cells deliver in parallel.
A well-formed task (the house standard):
- **Objective** — the outcome, not the implementation.
- **What This Builds** — the concrete artifacts.
- **The Work** — the per-cell breakdown (one cell for small work; Backend / Frontend / UX-UI for a feature).
- **Notes** — constraints, what to reuse, anything to confirm.
- **Success Criteria** — verifiable acceptance criteria.
## Read first, then ask
Before your first question, use `Read` / `Grep` / `Glob` and the read-only git verbs to learn the real surface. If the CEO says "put it on the Metrics page", open the Metrics page and see what's there. If they mention an endpoint, find it. Spawn research subagents (`Task`) when the codebase is large. **Ground every question and every claim in what the code actually shows** — never guess at a surface you could have read.
## Interview discipline
- Open by reflecting back, in a sentence or two, what you understand they want — so they can correct course immediately.
- Then **propose, don't interrogate.** After one round, state the task you'd build by default and ask only what you genuinely cannot infer from the code or the conversation. One or two questions per turn. Never dump a checklist.
- Stop the moment you could write a complete draft. Aim for two to four turns. Do not pad.
- Use the real names you find in the repo (files, pages, services, projects) — never invent a surface.
## Your tools
You have the built-in read tools `Read`, `Grep`, `Glob`, and `Task` (research subagents for a large codebase), plus **one** action tool: **`propose_draft`**. That's everything you have and everything you need — you read the code, you talk to the human, and when the spec is ready you call `propose_draft`. You have **no** `say`, `dm`, `notify`, git, or lifecycle verbs, no `Write`/`Edit`/`Bash`, **no plan mode / `ExitPlanMode`**, **no `ToolSearch`**, and **no `AskUserQuestion`** or any structured question/prompt tool — you never speak to another agent, never write code, never create or route a task. **You ask the human by simply writing your questions as plain text in this chat** — they read every message you send live, so the chat itself is your question channel. None of those Claude Code built-ins exist for you; reaching for one only stalls the turn. **You do not "plan" and wait** — when the spec is ready you call `propose_draft` directly; never announce that a plan is written and ask whether to proceed. **Your replies in this conversation are your entire output to the human, and `propose_draft` is the only way a draft leaves this chat.**
## Presenting the draft
When — and only when — you can write a complete spec:
1. Present it to the human in clear prose (Objective / What This Builds / The Work per cell / Notes / Success Criteria) so they can read and discuss it.
2. **Then call the `propose_draft` tool**, passing a JSON object in this shape (omit fields you don't have; `the_work` is one entry per participating cell). This is the *only* mechanism that produces the reviewable draft card — typing the JSON into the chat does nothing:
```json
{
"title": "Short imperative title",
"objective": "The outcome, not the implementation.",
"what_this_builds": ["concrete artifact", "another"],
"the_work": [
{"team": "backend", "summary": "what this cell does", "items": ["step", "step"]}
],
"notes": ["constraint or what to reuse"],
"acceptance_criteria": ["verifiable criterion", "another"],
"team": "backend",
"scale": "single",
"task_type": "code",
"nature": "technical",
"estimated_complexity": "medium",
"priority": 2
}
```
- `team` is the lead cell for single-cell work: one of `backend`, `frontend`, `ux_ui`. `scale` is `single` (one cell) or `multi` (board-led across cells).
- Call `propose_draft` only once you're confident — it's what the human reviews and confirms. If the conversation continues and the spec changes, call it again with the updated draft.
- Don't call it with a partial or speculative draft just to fill a turn. Prose-only is correct until the spec is real.
## What happens after you call `propose_draft`
A draft card appears for the human with three choices: **Keep chatting**, **Board review & Start**, or **Approve & Start**. **Choosing is the human's action, not yours** — you cannot create, start, or route the task. If they pick **Board review & Start**, it becomes a pending task owned by the Board (Product Owner + Head of Marketing) to review first; if they pick **Approve & Start**, it becomes a pending task that goes straight to the Main PM to delegate to the cells. Either way, your job ends the moment you call `propose_draft`. Do not say you'll "kick it off", "send it to the PM chain", or route it anywhere — you have no such ability, and which path it takes is the human's choice on the card.
## Workflow
1. Read the scoped repo(s) to ground yourself in the real surface.
2. Reflect back your understanding; ask only the highest-leverage missing questions, one or two at a time.
3. Once you can write a complete spec, present it in prose **and call `propose_draft`**. The human then reviews, confirms, or keeps chatting.
## Anti-patterns
- ❌ Asking generic SaaS questions (users, access, permissions, multi-tenancy). One human, the CEO.
- ❌ Interrogating instead of proposing — extracting answers the CEO already gave, or that the code already answers.
- ❌ Asking about a surface you could have read. Open the file first.
- ❌ Typing the draft JSON into the chat instead of calling `propose_draft` — only the tool produces the card.
- ❌ Reaching for `AskUserQuestion` or any question/prompt UI tool to ask the CEO something — you ask by writing in the chat. That tool isn't yours and does nothing here.
- ❌ Entering plan mode, calling `ExitPlanMode`, or `ToolSearch`/`Write` — none exist for you. Your plan IS the `propose_draft` draft; when the spec is ready, call it directly instead of announcing a plan and waiting.
- ❌ Claiming you'll route, delegate, or hand off the task (to the Main PM or anyone). You draft; the human confirms; the Board reviews; the Main PM delegates — none of that is yours to do.
@@ -0,0 +1,32 @@
"""Add 'prompter' to the postgres agentrole enum.
The intake interviewer is a new agent role (``Role.PROMPTER`` in
foundation/identity). Seeding/spawning its agent row requires the postgres
``agentrole`` enum to carry the value. Mirrors migration 012's pattern.
Revision ID: 025_agentrole_prompter
Revises: 024_add_prompter_tables
Create Date: 2026-06-08
"""
from __future__ import annotations
from alembic import op
revision = "025_agentrole_prompter"
down_revision = "024_add_prompter_tables"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Unguarded (renders in offline --sql so the enum-migration-parity test
# sees it) and idempotent. PG 16 permits ADD VALUE inside a transaction —
# same pattern as migration 020's backfill.
op.execute("ALTER TYPE agentrole ADD VALUE IF NOT EXISTS 'prompter'")
def downgrade() -> None:
# Postgres does not support removing enum values without a destructive
# type recreation. Forward-only by design (see migration 012).
pass
+343
View File
@@ -0,0 +1,343 @@
services:
# ==========================================================================
# PostgreSQL - Primary Database with pgvector for RAG
# ==========================================================================
postgres:
image: pgvector/pgvector:pg16
container_name: roboco-postgres
restart: unless-stopped
environment:
POSTGRES_USER: roboco
POSTGRES_PASSWORD: roboco
POSTGRES_DB: roboco
ports:
- "15432:5432"
volumes:
- ${ROBOCO_DATA_DIR:-./data}/postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U roboco -d roboco"]
interval: 10s
timeout: 5s
retries: 5
# ==========================================================================
# Redis - Cache, Sessions, Event Bus
# ==========================================================================
redis:
image: redis:8-alpine
container_name: roboco-redis
restart: unless-stopped
command: redis-server --appendonly yes
ports:
- "16379:6379"
volumes:
- ${ROBOCO_DATA_DIR:-./data}/redis:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
# ==========================================================================
# Ollama - Local LLM and Embedding Server
# ==========================================================================
ollama:
image: ollama/ollama:latest
container_name: roboco-ollama
restart: unless-stopped
environment:
OLLAMA_API_KEY: ${OLLAMA_API_KEY}
ports:
- "11435:11434"
volumes:
- ${ROBOCO_DATA_DIR:-./data}/ollama:/root/.ollama
healthcheck:
# Use ollama CLI (guaranteed available) to check if server is responding
test: ["CMD", "ollama", "list"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
# Ollama model puller - pulls required models on startup
# Uses streaming curl to wait for full model download
ollama-init:
image: curlimages/curl:latest
container_name: roboco-ollama-init
depends_on:
ollama:
condition: service_healthy
restart: "no"
entrypoint: ["/bin/sh", "-c"]
command:
- |
set -e
echo "=== Pulling embedding model (qwen3-embedding:0.6b) ==="
# Ollama /api/pull streams JSON lines until complete - consume full stream
# Note: $$ escapes $ for docker-compose variable substitution
curl -sN http://ollama:11434/api/pull -d '{"name":"qwen3-embedding:0.6b"}' | while read -r line; do
status=$$(echo "$$line" | grep -o '"status":"[^"]*"' | cut -d'"' -f4)
[ -n "$$status" ] && echo " $$status"
done
echo "=== Pulling LLM model (glm-5:cloud) ==="
curl -sN http://ollama:11434/api/pull -d '{"name":"glm-5:cloud"}' | while read -r line; do
status=$$(echo "$$line" | grep -o '"status":"[^"]*"' | cut -d'"' -f4)
[ -n "$$status" ] && echo " $$status"
done
echo "=== Verifying models are available ==="
curl -sf http://ollama:11434/api/tags | grep -q "qwen3-embedding" && echo " qwen3-embedding: OK"
curl -sf http://ollama:11434/api/tags | grep -q "glm-5" && echo " glm-5: OK"
echo "=== All models ready! ==="
# ==========================================================================
# Agent Base Image Builder (specialized images built on-demand by orchestrator)
# ==========================================================================
agent-base-image:
build:
context: .
dockerfile: docker/agent-base.Dockerfile
image: roboco-agent-base
container_name: roboco-agent-base-builder
entrypoint: ["/bin/sh", "-c", "echo 'Agent base image built successfully'"]
restart: "no"
# ==========================================================================
# Agent PM Image Builder (specialized image built on-demand by orchestrator)
# ==========================================================================
agent-pm-image:
build:
context: .
dockerfile: docker/agent-pm.Dockerfile
image: roboco-agent-pm
entrypoint: ["/bin/sh", "-c", "echo 'Agent PM image built'"]
restart: "no"
depends_on:
- agent-base-image
# ==========================================================================
# Agent Backend Dev Image Builder (specialized image built on-demand by orchestrator)
# ==========================================================================
agent-dev-be-image:
build:
context: .
dockerfile: docker/agent-dev-be.Dockerfile
image: roboco-agent-dev-be
entrypoint: ["/bin/sh", "-c", "echo 'Agent Backend Dev image built'"]
restart: "no"
depends_on:
- agent-base-image
# ==========================================================================
# Agent Frontend Dev Image Builder (specialized image built on-demand by orchestrator)
# ==========================================================================
agent-dev-fe-image:
build:
context: .
dockerfile: docker/agent-dev-fe.Dockerfile
image: roboco-agent-dev-fe
entrypoint: ["/bin/sh", "-c", "echo 'Agent Frontend Dev image built'"]
restart: "no"
depends_on:
- agent-base-image
# ==========================================================================
# Agent Backend QA Image Builder (specialized image built on-demand by orchestrator)
# ==========================================================================
agent-qa-be-image:
build:
context: .
dockerfile: docker/agent-qa-be.Dockerfile
image: roboco-agent-qa-be
entrypoint: ["/bin/sh", "-c", 'echo "Agent Backend QA image built"']
restart: "no"
depends_on:
- agent-base-image
# ==========================================================================
# Agent Frontend QA Image Builder (specialized image built on-demand by orchestrator)
# ==========================================================================
agent-qa-fe-image:
build:
context: .
dockerfile: docker/agent-qa-fe.Dockerfile
image: roboco-agent-qa-fe
entrypoint: ["/bin/sh", "-c", 'echo "Agent Frontend QA image built"']
restart: "no"
depends_on:
- agent-base-image
# ==========================================================================
# Agent UX/UI Dev Image Builder (specialized image built on-demand by orchestrator)
# ==========================================================================
agent-ux-image:
build:
context: .
dockerfile: docker/agent-ux.Dockerfile
image: roboco-agent-ux
entrypoint: ["/bin/sh", "-c", 'echo "Agent UX/UI image built"']
restart: "no"
depends_on:
- agent-base-image
# ==========================================================================
# Agent Documenter Image Builder (specialized image built on-demand by orchestrator)
# ==========================================================================
agent-doc-image:
build:
context: .
dockerfile: docker/agent-doc.Dockerfile
image: roboco-agent-doc
entrypoint: ["/bin/sh", "-c", 'echo "Agent Documenter image built"']
restart: "no"
depends_on:
- agent-base-image
# ==========================================================================
# Agent Intake (Prompter) Image Builder (persistent Agent-SDK driver the CEO
# chats with; not a one-shot `claude -p`)
# ==========================================================================
agent-prompter-image:
build:
context: .
dockerfile: docker/agent-prompter.Dockerfile
image: roboco-agent-prompter
entrypoint: ["/bin/sh", "-c", 'echo "Agent Intake image built"']
restart: "no"
depends_on:
- agent-base-image
# ==========================================================================
# Orchestrator - API Server + Agent Spawner
# ==========================================================================
orchestrator:
build:
context: .
dockerfile: docker/orchestrator.Dockerfile
image: roboco-orchestrator
container_name: roboco-orchestrator
restart: unless-stopped
ports:
- "8000:8000"
environment:
# Database (use container name, not localhost)
ROBOCO_DATABASE_HOST: roboco-postgres
ROBOCO_DATABASE_PORT: 5432
ROBOCO_DATABASE_USER: roboco
ROBOCO_DATABASE_PASSWORD: roboco
ROBOCO_DATABASE_NAME: roboco
# Redis (use container name)
ROBOCO_REDIS_HOST: roboco-redis
ROBOCO_REDIS_PORT: 6379
# API
ROBOCO_HOST: 0.0.0.0
ROBOCO_PORT: 8000
ROBOCO_ENCRYPTION_KEY: ${ROBOCO_ENCRYPTION_KEY:?ROBOCO_ENCRYPTION_KEY is required}
# HMAC secret for agent auth tokens. Orchestrator signs tokens
# per-agent at spawn; API middleware verifies them. Generate with:
# python -c 'import secrets; print(secrets.token_hex(32))'
ROBOCO_AGENT_AUTH_SECRET: ${ROBOCO_AGENT_AUTH_SECRET:?ROBOCO_AGENT_AUTH_SECRET is required}
# Set to "true" to require tokens on every API call (fail-closed).
# Leave unset/false during rollout so the panel + curl still work.
ROBOCO_AGENT_AUTH_REQUIRED: ${ROBOCO_AGENT_AUTH_REQUIRED:-false}
# Ollama (use container name)
ROBOCO_LOCAL_LLM_BASE_URL: http://roboco-ollama:11434/v1
ROBOCO_LOCAL_LLM_MODEL: glm-5:cloud
ROBOCO_DEFAULT_EMBEDDING_MODEL: qwen3-embedding:0.6b
ROBOCO_OLLAMA_BASE_URL: http://roboco-ollama:11434
# Host paths for spawning agent containers (required for Docker-in-Docker)
# IMPORTANT: These must be ABSOLUTE paths on the host filesystem
ROBOCO_HOST_PROJECT_DIR: ${ROBOCO_HOST_PROJECT_DIR:-/volume1/roboco}
ROBOCO_HOST_CLAUDE_DIR: ${ROBOCO_HOST_CLAUDE_DIR:-/home/renzof/.claude}
ROBOCO_HOST_DATA_DIR: ${ROBOCO_HOST_DATA_DIR:-/volume1/roboco/data}
# Public base URL for commit-trailer links. Default 127.0.0.1 produces
# unusable links in commit message bodies; set to NAS LAN IP so
# f"{api_base}/tasks/{task_id}" renders a reachable URL.
ROBOCO_PUBLIC_BASE_URL: "http://192.168.50.111:8000"
# Production environment selects structlog's JSONRenderer (machine-
# parseable logs) over the dev ConsoleRenderer.
ROBOCO_ENVIRONMENT: production
volumes:
# Docker socket - allows spawning agent containers
- /var/run/docker.sock:/var/run/docker.sock
# Claude Code auth - mount your ~/.claude directory
- ${CLAUDE_AUTH_DIR:-/home/renzof/.claude}:/root/.claude
# Shared config directory for MCP configs (writable)
- ${ROBOCO_DATA_DIR:-./data}/mcp-configs:/app/mcp-configs
# Generated prompts directory - composed at runtime from layers
- ${ROBOCO_DATA_DIR:-./data}/prompts-generated:/app/prompts-generated
# Per-agent Claude settings (generated at spawn time)
- ${ROBOCO_DATA_DIR:-./data}/agent-settings:/app/agent-settings
# Agent workspaces (git clones) - persisted across restarts
- ${ROBOCO_DATA_DIR:-./data}/workspaces:/data/workspaces
# Persistent logs — survive `docker compose down/up`. Orchestrator and
# each spawned agent write structured logs here so we can audit past
# runs instead of relying on ephemeral `docker logs`.
- ${ROBOCO_DATA_DIR:-./data}/logs:/data/logs
# Per-agent SessionStart briefings (pre-rendered task context)
- ${ROBOCO_DATA_DIR:-./data}/briefings:/app/briefings
# Per-agent spawn manifests (role-scoped tool list) — written by the
# orchestrator to /app/manifests, bind-mounted into each agent container
# as /app/tool-manifest.json. Without this mount the file is written to
# the orchestrator's ephemeral fs, never reaches the host, and agents
# fall back to all-verbs registration.
- ${ROBOCO_DATA_DIR:-./data}/manifests:/app/manifests
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
ollama:
condition: service_healthy
ollama-init:
condition: service_completed_successfully
agent-base-image:
condition: service_completed_successfully
# Default agents to spawn (override in .env or command line)
# command: ["--spawn", "main-pm", "be-dev-1", "be-qa"]
# ==========================================================================
# Next.js Control Panel (Frontend)
# ==========================================================================
# Not exposed directly - nginx is the single entry point (port 3000).
# API/WS traffic is proxied to the orchestrator, everything else to panel.
panel:
build:
context: .
dockerfile: docker/panel.Dockerfile
image: roboco-panel
container_name: roboco-panel
restart: unless-stopped
expose:
- "3000"
depends_on:
- orchestrator
# ==========================================================================
# Nginx - Reverse proxy fronting panel + orchestrator
# ==========================================================================
# Single entry point on port 3000 so the browser hits one origin and we
# don't need CORS. /api/* and /ws/* go to the orchestrator, everything
# else goes to the Next.js panel.
nginx:
image: nginx:alpine
container_name: roboco-nginx
restart: unless-stopped
ports:
- "3000:80"
environment:
# Rendered into the proxy config by the nginx image's envsubst
# entrypoint so the human panel authenticates in secure mode. The
# filter limits substitution to ROBOCO_* vars, leaving nginx's own
# $host / $remote_addr runtime variables untouched. Get the value
# with `make panel-token`.
ROBOCO_PANEL_AGENT_TOKEN: ${ROBOCO_PANEL_AGENT_TOKEN:-}
NGINX_ENVSUBST_FILTER: "^ROBOCO_"
volumes:
- ./docker/nginx.conf:/etc/nginx/templates/default.conf.template:ro
depends_on:
- panel
- orchestrator
networks:
default:
name: roboco_default
+14
View File
@@ -192,6 +192,20 @@ services:
depends_on:
- agent-base-image
# ==========================================================================
# Agent Intake (Prompter) Image Builder (persistent Agent-SDK driver the CEO
# chats with; not a one-shot `claude -p`)
# ==========================================================================
agent-prompter-image:
build:
context: .
dockerfile: docker/agent-prompter.Dockerfile
image: roboco-agent-prompter
entrypoint: ["/bin/sh", "-c", 'echo "Agent Intake image built"']
restart: "no"
depends_on:
- agent-base-image
# ==========================================================================
# Orchestrator - API Server + Agent Spawner
# ==========================================================================
+5 -1
View File
@@ -80,8 +80,12 @@ USER agent
# a per-agent sandbox that only mounts its own workspace.
RUN git config --global --add safe.directory '*'
# PYTHONUNBUFFERED: flush stdout/stderr immediately so the SDK driver's logs
# (e.g. the intake agent's turn-received / streamed lines) reach `docker logs` in
# real time instead of block-buffering until the container is reaped.
ENV PATH="/app/.venv/bin:$PATH" \
VIRTUAL_ENV=/app/.venv
VIRTUAL_ENV=/app/.venv \
PYTHONUNBUFFERED=1
# Claude Code uses mounted ~/.claude for auth.
# System prompt mounted at /app/system-prompt.md at spawn time.
+16
View File
@@ -0,0 +1,16 @@
# Intake (Prompter) Agent — the interactive Claude Code session the CEO chats with.
#
# Unlike every other agent (a one-shot `claude -p` that does a task and exits),
# the intake agent runs a PERSISTENT driver: it holds one `claude-agent-sdk`
# `ClaudeSDKClient` open, receives the human's messages over HTTP (POST /turn),
# and streams each reply back to the panel. `claude-agent-sdk` is already in the
# base image (it's a main dependency); the SDK drives the same `claude` binary
# the base ships, using the same mounted ~/.claude auth — no API key.
FROM roboco-agent-base
LABEL role="prompter"
LABEL description="Intake interviewer — a long-lived Claude Agent SDK session driven by the panel"
# Override the base `["claude"]` entrypoint with the intake driver. WORKDIR /app
# and the venv on PATH are inherited from the base; roboco lives at /app/roboco.
ENTRYPOINT ["python", "-m", "roboco.agent_sdk.intake_main"]
+6 -1
View File
@@ -84,8 +84,13 @@ COPY --from=builder /usr/local/bin/uv /usr/local/bin/uv
# the sandboxed /data/workspaces tree.
RUN git config --global --add safe.directory '*'
# PYTHONUNBUFFERED: flush stdout/stderr immediately so structured logs reach
# `docker logs` in real time. Without it Python block-buffers stdout (it's a pipe,
# not a TTY) and lines arrive in large delayed chunks — which made live log
# diagnosis impossible during the intake smoke tests.
ENV PATH="/app/.venv/bin:$PATH" \
VIRTUAL_ENV=/app/.venv
VIRTUAL_ENV=/app/.venv \
PYTHONUNBUFFERED=1
EXPOSE 8000
+53 -15
View File
@@ -1,6 +1,8 @@
# How RoboCo works
RoboCo is a virtual software company18 AI agents and one human: you. Not a
![Twelve-second looping preview of the RoboCo control panelthe org tree, a task in progress, and an approval queue.](videos/panel-teaser.gif)
RoboCo is a virtual software company — 20 AI agents and one human: you. Not a
swarm of bots, not a framework to wire together — an **organization**, with
roles, a chain of command, formal reviews, and sign-offs. You don't micromanage
it; you run it like a CEO. Drop work in at the top and the company carries it all
@@ -8,7 +10,7 @@ the way through planning, building, review, and documentation, then brings it
back to your desk for the final word. You act at the two ends; the organization
fills in everything between.
What keeps eighteen agents from dissolving into noise is that RoboCo is
What keeps twenty agents from dissolving into noise is that RoboCo is
relentlessly opinionated about *how* work happens: everything is a task, no task
moves without acceptance criteria, and every task walks the same strict lifecycle
— built, QA'd, documented, PM-reviewed, approved — each step gated by role. The
@@ -29,8 +31,6 @@ proof of concept.
walks through every page and detail end-to-end — useful as a first tour before
diving into the screenshots below.
![Twelve-second looping preview of the RoboCo control panel — the org tree, a task in progress, and an approval queue.](videos/panel-teaser.gif)
![The RoboCo Command Center: per-cell health, the CEO approval queue, live metrics, auditor alerts, and recent activity.](images/overview_dashboard.png)
*The **Command Center** — a glance tells you how each cell is doing, what's
@@ -45,9 +45,10 @@ org itself:
```
CEO (you, the human)
├── Intake (on-demand interviewer — drafts a task with you)
└── Board ── Product Owner · Head of Marketing · Auditor (silent)
└── Main PM (coordinates the cells)
├── UX/UI cell ── PM · Dev · QA · Documenter
├── UX/UI cell ── PM · 2 Devs · QA · Documenter
├── Frontend cell ── PM · 2 Devs · QA · Documenter
└── Backend cell ── PM · 2 Devs · QA · Documenter
```
@@ -68,18 +69,55 @@ awaiting review, completed — on its own branch.*
### 1 · It starts with you
You describe what you want — a feature, a fix, an entire product — and hand it to
the **Board**. Their job is to pin it down: the Product Owner and Head of
Marketing turn a loose request into a concrete spec, with the acceptance criteria
that define what "finished" actually means. The Auditor watches the whole time
but never interferes.
You describe what you want — a feature, a fix, an entire product. The way in is
the **Task Assistant** — which is the **Prompter** itself, the very feature whose
build the rest of this page follows. (You're about to use the tool RoboCo built
for itself; further down, you'll watch the company build it.) Instead of filling
a form from memory, you give it a rough idea and it reads your *actual* codebase,
asks a few sharp questions, and hands back a properly-formed task — an objective,
a per-cell breakdown, and the acceptance criteria that define what "finished"
really means.
![The CEO's task-definition form: the title, description, and acceptance criteria that start a task.](images/task_definition_1.png)
![The Task Assistant's scope form: pick the project or product to work in, then describe what you want to build.](images/start_prompter.png)
![The same task-definition form, scrolled or scrolled-onto the acceptance criteria and submit step.](images/task_definition_2.png)
*Where it starts — point the assistant at a project (one repo) or a product
(several), drop in a rough idea, and it spins up an agent that reads that code
before it says a word.*
*Filing the brief — title, description, and the must-haves captured up front so
nothing is implicit and nothing is lost.*
![The Task Assistant chat opening: the idea is in, and the agent is cloning the repo and reading the code before it answers.](images/prompter_run_1.png)
*No canned questions. The agent clones the scope and reads the real surface
first, so everything it asks and proposes is grounded in what your code actually
does.*
<!-- Optional: re-capture prompter_run_2 after the markdown-rendering fix ships (its headers will render cleanly instead of as raw ###). -->
![The agent's grounded analysis: a read of the existing surface, what's missing, where the feature should live, and a proposed shape — citing real files and pages.](images/prompter_run_2.png)
*It comes back having done the homework — naming the real pages, services, and
files, laying out what to build and where, and refining with you over a couple of
turns until the spec is right.*
<!-- prompter_draft_card.png is the captured smoke shot; optionally re-capture after the draft-card cell-badge dedupe ships, for cleaner "Board-led across Backend Frontend" badges. -->
![The draft proposal card: the finished task — objective, per-cell work, and acceptance criteria — with three choices: Keep chatting, Board review & Start, or Approve & Start.](images/prompter_draft_card.png)
*The proposal, ready to launch. Keep chatting to refine it, send it to the
**Board** for review, or approve it straight to the Main PM — your call, on one
card.*
![The Task Assistant's confirmation: the task has been created and handed to the company.](images/prompter_task_accepted.png)
![The created task, live: its objective, the per-cell breakdown, status, and assignment — exactly as the company will work it.](images/prompter_task_created.png)
*From a rough sentence to a real, scoped task in a single chat — acceptance
criteria and all, already moving through the company.*
From here, every task follows the path you chose for it. To show that journey end
to end, the rest of this page follows the **Prompter's own** trip through the
company — from this same starting point to a merged pull request. Send a task to
the **Board** and their job is to pin it down: the Product Owner and Head of
Marketing turn the draft into a settled spec, sharpening the requirements and the
acceptance criteria before anyone writes a line of code. The Auditor watches the
whole time but never interferes.
![A Board review session: the Product Owner writing out requirements and acceptance criteria for a task.](images/chat_session.png)
@@ -222,7 +260,7 @@ for a demo — it's a real page RoboCo's agents shipped to RoboCo's own control
panel. A company building its own product, in front of you, is the whole point of
RoboCo. What makes that hold together isn't a clever model or a lucky run; it's
the **organization** — the roles, the gated lifecycle, the reviews and the
sign-offs that keep eighteen agents moving as a company instead of a crowd. Run
sign-offs that keep twenty agents moving as a company instead of a crowd. Run
as many of these passes as you like, across as many projects as you like.
---
Binary file not shown.

After

Width:  |  Height:  |  Size: 388 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 315 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 470 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

+1 -1
View File
@@ -98,7 +98,7 @@ Submit work for QA. Auto-runs in_progress->verifying then verifying->awaiting_qa
Signal you have no active work. PMs auto-pause owned in_progress tasks.
**Allowed roles:** auditor, cell_pm, developer, documenter, head_marketing, main_pm, product_owner, qa
**Allowed roles:** auditor, cell_pm, developer, documenter, head_marketing, main_pm, product_owner, prompter, qa
**Composes:** (no atomic actions)
+1
View File
@@ -158,6 +158,7 @@
"head_marketing",
"main_pm",
"product_owner",
"prompter",
"qa"
],
"composes": [],
+82 -46
View File
@@ -1,12 +1,13 @@
"use client";
import { Sparkles } from "lucide-react";
import { Loader2, Sparkles, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { usePrompter } from "@/hooks/use-prompter";
import {
ChatMessages,
ChatComposer,
ConfirmDialog,
SuccessCard,
IntakeForm,
} from "@/components/prompter";
export default function PrompterPage() {
@@ -14,23 +15,30 @@ export default function PrompterPage() {
state,
messages,
isSending,
editableDraft,
activity,
createdTaskId,
createdTaskTitle,
createdTaskTeam,
targetKind,
setTargetKind,
projectId,
setProjectId,
productId,
setProductId,
initialMessage,
setInitialMessage,
isFormValid,
start,
send,
openReview,
closeReview,
keepChatting,
updateDraft,
isValidForLaunch,
launchTask,
startAnother,
isLaunching,
} = usePrompter();
const showForm = state === "form" || state === "preparing";
const isComposerDisabled =
state === "launching" || state === "success";
state === "launching" || state === "success" || isSending;
return (
<div className="flex h-full flex-col">
@@ -40,54 +48,82 @@ export default function PrompterPage() {
<div>
<h1 className="text-lg font-semibold">Task Assistant</h1>
<p className="text-xs text-muted-foreground">
Describe your idea and I&apos;ll help you create a structured task
Chat with an agent that reads your code and drafts the task
</p>
</div>
{/* End chat — reap the agent and return to the form (any chat state) */}
{!showForm && state !== "success" && (
<Button
variant="ghost"
size="sm"
className="ml-auto text-muted-foreground"
onClick={startAnother}
disabled={state === "launching"}
>
<X className="mr-1 h-4 w-4" />
End chat
</Button>
)}
</div>
{/* Chat area */}
<div className="flex flex-1 flex-col overflow-hidden">
{/* Success overlay in chat area */}
{state === "success" &&
{showForm ? (
<IntakeForm
targetKind={targetKind}
onTargetKind={setTargetKind}
projectId={projectId}
onProjectId={setProjectId}
productId={productId}
onProductId={setProductId}
initialMessage={initialMessage}
onInitialMessage={setInitialMessage}
isValid={isFormValid()}
isPreparing={state === "preparing"}
onStart={start}
/>
) : (
<div className="flex flex-1 flex-col overflow-hidden">
{/* Success overlay in chat area */}
{state === "success" &&
createdTaskId &&
createdTaskTitle &&
createdTaskTeam ? (
<div className="flex flex-1 flex-col items-center justify-center px-8 py-8">
<div className="w-full max-w-md">
<SuccessCard
taskId={createdTaskId}
taskTitle={createdTaskTitle}
team={createdTaskTeam}
onStartAnother={startAnother}
/>
<div className="flex flex-1 flex-col items-center justify-center px-8 py-8">
<div className="w-full max-w-md">
<SuccessCard
taskId={createdTaskId}
taskTitle={createdTaskTitle}
team={createdTaskTeam}
onStartAnother={startAnother}
/>
</div>
</div>
</div>
) : (
<ChatMessages
messages={messages}
onOpenReview={openReview}
onKeepChatting={keepChatting}
/>
)}
) : (
<ChatMessages
messages={messages}
onStart={launchTask}
onKeepChatting={keepChatting}
isLaunching={isLaunching}
/>
)}
{/* Composer */}
<ChatComposer
onSend={send}
disabled={isComposerDisabled}
isSending={isSending}
/>
</div>
{/* Live activity indicator — "watch it work" (prominent) */}
{activity && state !== "success" && (
<div className="mx-4 mb-2 flex items-center gap-2.5 rounded-lg border border-primary/30 bg-primary/10 px-4 py-2.5 text-sm font-medium text-primary">
<Loader2 className="h-4 w-4 shrink-0 animate-spin" />
<span>{activity}</span>
</div>
)}
{/* Confirmation dialog (portal) */}
<ConfirmDialog
open={state === "review_modal" || state === "launching"}
draft={editableDraft}
onClose={closeReview}
onUpdate={updateDraft}
onConfirm={launchTask}
isLaunching={isLaunching}
isValid={isValidForLaunch()}
/>
{/* Composer */}
{state !== "success" && (
<ChatComposer
onSend={send}
disabled={isComposerDisabled}
isSending={isSending}
/>
)}
</div>
)}
</div>
);
}
@@ -1,21 +1,70 @@
"use client";
import { useEffect, useRef } from "react";
import type { ComponentPropsWithoutRef, ReactElement, ReactNode } from "react";
import { AlertTriangle } from "lucide-react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { cn } from "@/lib/utils";
import type { ChatMessage } from "@/hooks/use-prompter";
import { CopyButton } from "@/components/ui/copy-button";
import type { ChatMessage, StartRoute } from "@/hooks/use-prompter";
import { DraftProposalCard } from "./draft-proposal-card";
interface ChatMessagesProps {
messages: ChatMessage[];
onOpenReview: () => void;
onStart: (route: StartRoute) => void;
onKeepChatting: () => void;
isLaunching?: boolean;
}
/** Raw text of a fenced code block — the <pre>'s <code> child's string content. */
function codeText(children: ReactNode): string {
const codeEl = children as ReactElement<{ children?: ReactNode }> | undefined;
const inner = codeEl?.props?.children;
if (typeof inner === "string") return inner;
if (Array.isArray(inner)) {
return inner.filter((c): c is string => typeof c === "string").join("");
}
return "";
}
// Copy lives on KEY PARTS only: fenced code blocks here, and the draft card has
// its own. (Not a blanket button on every whole message.)
const markdownComponents = {
pre(props: ComponentPropsWithoutRef<"pre">) {
const text = codeText(props.children).replace(/\n$/, "");
return (
<div className="group relative">
<pre {...props} />
{text && (
<CopyButton
value={text}
className="absolute right-1.5 top-1.5 bg-background/80 opacity-0 transition-opacity group-hover:opacity-100"
/>
)}
</div>
);
},
};
/** GFM markdown that inherits the bubble's text color, so it renders correctly on
* both the muted assistant bubble and the primary user bubble (lists, code,
* newlines all preserved). */
function MarkdownBody({ content }: { content: string }) {
return (
<div className="prose prose-sm max-w-none [&_*]:!text-inherit prose-p:my-1.5 prose-headings:mt-3 prose-headings:mb-1 prose-pre:my-2 prose-pre:bg-black/20">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{content}
</ReactMarkdown>
</div>
);
}
export function ChatMessages({
messages,
onOpenReview,
onStart,
onKeepChatting,
isLaunching,
}: ChatMessagesProps) {
const bottomRef = useRef<HTMLDivElement>(null);
@@ -43,7 +92,7 @@ export function ChatMessages({
return (
<div key={msg.id} className="flex justify-end">
<div className="max-w-[70%] rounded-2xl rounded-tr-sm bg-primary px-4 py-3 text-sm text-primary-foreground">
{msg.content}
<MarkdownBody content={msg.content} />
</div>
</div>
);
@@ -66,11 +115,11 @@ export function ChatMessages({
<div className="flex justify-start">
<div
className={cn(
"max-w-[70%] rounded-2xl rounded-tl-sm bg-muted px-4 py-3 text-sm",
"max-w-[70%] rounded-2xl rounded-tl-sm bg-muted px-4 py-3 text-sm text-foreground",
msg.draft && "max-w-[85%]"
)}
>
<p className="whitespace-pre-wrap">{msg.content}</p>
<MarkdownBody content={msg.content} />
</div>
</div>
@@ -81,7 +130,8 @@ export function ChatMessages({
<DraftProposalCard
draft={msg.draft}
onKeepChatting={onKeepChatting}
onOpenReview={onOpenReview}
onStart={onStart}
isLaunching={isLaunching}
/>
</div>
</div>
@@ -1,212 +0,0 @@
"use client";
import { AlertTriangle, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { AcceptanceCriteriaEditor } from "@/components/tasks/acceptance-criteria-editor";
import { MarkdownEditor } from "@/components/tasks/markdown-editor";
import { Team, TaskType, Complexity } from "@/types";
import type { EditableDraft } from "@/hooks/use-prompter";
interface ConfirmDialogProps {
open: boolean;
draft: EditableDraft;
onClose: () => void;
onUpdate: (updates: Partial<EditableDraft>) => void;
onConfirm: () => Promise<void> | void;
isLaunching: boolean;
isValid: boolean;
}
const WARNING_BANNER_ID = "prompter-warning-banner";
export function ConfirmDialog({
open,
draft,
onClose,
onUpdate,
onConfirm,
isLaunching,
isValid,
}: ConfirmDialogProps) {
return (
<Dialog open={open} onOpenChange={(o) => { if (!o && !isLaunching) onClose(); }}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Review &amp; Confirm Task</DialogTitle>
</DialogHeader>
{/* Warning banner */}
<div
id={WARNING_BANNER_ID}
className="flex items-start gap-2 rounded-lg border border-destructive/40 bg-destructive/10 px-4 py-3 text-sm text-destructive"
>
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<span>
This will create a real task and notify the team. It cannot be undone from this screen.
</span>
</div>
{/* Form fields — NOT wrapped in a <form> to prevent Enter-key submission bypass */}
<div className="space-y-5 py-2">
{/* Title */}
<div className="space-y-1.5">
<Label htmlFor="prompter-title">
Title <span className="text-destructive">*</span>
</Label>
<Input
id="prompter-title"
value={draft.title}
onChange={(e) => onUpdate({ title: e.target.value })}
placeholder="Task title"
disabled={isLaunching}
/>
</div>
{/* Description */}
<MarkdownEditor
label="Description"
value={draft.description}
onChange={(v) => onUpdate({ description: v })}
placeholder="Describe what needs to be done…"
required
minLength={20}
/>
{/* Acceptance Criteria */}
<AcceptanceCriteriaEditor
criteria={draft.acceptance_criteria}
onChange={(criteria) => onUpdate({ acceptance_criteria: criteria })}
/>
{/* Metadata row */}
<div className="grid grid-cols-3 gap-4">
{/* Team */}
<div className="space-y-1.5">
<Label>
Team <span className="text-destructive">*</span>
</Label>
<Select
value={draft.team}
onValueChange={(v) => onUpdate({ team: v as Team })}
disabled={isLaunching}
>
<SelectTrigger>
<SelectValue placeholder="Select team" />
</SelectTrigger>
<SelectContent>
{Object.values(Team).map((t) => (
<SelectItem key={t} value={t}>
{t.replace("_", " ")}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Priority */}
<div className="space-y-1.5">
<Label>Priority</Label>
<Select
value={String(draft.priority)}
onValueChange={(v) => onUpdate({ priority: Number(v) })}
disabled={isLaunching}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="0">Low</SelectItem>
<SelectItem value="1">Medium</SelectItem>
<SelectItem value="2">High</SelectItem>
<SelectItem value="3">Urgent</SelectItem>
</SelectContent>
</Select>
</div>
{/* Task Type */}
<div className="space-y-1.5">
<Label>Type</Label>
<Select
value={draft.task_type || ""}
onValueChange={(v) => onUpdate({ task_type: v as TaskType })}
disabled={isLaunching}
>
<SelectTrigger>
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
{Object.values(TaskType).map((t) => (
<SelectItem key={t} value={t}>
{t.charAt(0).toUpperCase() + t.slice(1)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Complexity */}
<div className="space-y-1.5">
<Label>Estimated Complexity</Label>
<Select
value={draft.estimated_complexity || ""}
onValueChange={(v) => onUpdate({ estimated_complexity: v as Complexity })}
disabled={isLaunching}
>
<SelectTrigger className="w-48">
<SelectValue placeholder="Select complexity" />
</SelectTrigger>
<SelectContent>
{Object.values(Complexity).map((c) => (
<SelectItem key={c} value={c}>
{c.charAt(0).toUpperCase() + c.slice(1)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter className="gap-2">
<Button
variant="outline"
onClick={onClose}
disabled={isLaunching}
>
Back
</Button>
<Button
onClick={onConfirm}
disabled={!isValid || isLaunching}
aria-describedby={WARNING_BANNER_ID}
>
{isLaunching ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Creating
</>
) : (
"Confirm & Launch"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -1,30 +1,76 @@
"use client";
import { MessageCircle, ClipboardCheck } from "lucide-react";
import { MessageCircle, Users, Rocket, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { CopyButton } from "@/components/ui/copy-button";
import type { DraftProposal } from "@/lib/api/prompter";
import type { StartRoute } from "@/hooks/use-prompter";
interface DraftProposalCardProps {
draft: DraftProposal;
onKeepChatting: () => void;
onOpenReview: () => void;
onStart: (route: StartRoute) => void;
/** A launch is in flight — disable the actions so a double-click can't dupe. */
isLaunching?: boolean;
}
// 0 is the highest priority, 3 the lowest — matches the backend contract.
const PRIORITY_LABELS: Record<number, string> = {
0: "Low",
1: "Medium",
2: "High",
3: "Urgent",
0: "Urgent",
1: "High",
2: "Medium",
3: "Low",
};
const cellLabel = (team: string) =>
team === "ux_ui" ? "UX/UI" : team.charAt(0).toUpperCase() + team.slice(1);
/** Render the draft as plain markdown text for the copy button, so the CEO can
* stash the full spec elsewhere (a safety net until refresh-durability lands). */
function draftToText(draft: DraftProposal): string {
const lines: string[] = [`# ${draft.title}`, ""];
if (draft.objective) lines.push("## Objective", draft.objective, "");
if (draft.what_this_builds?.length) {
lines.push(
"## What This Builds",
...draft.what_this_builds.map((b) => `- ${b}`),
""
);
}
if (draft.the_work?.length) {
lines.push("## The Work");
for (const cell of draft.the_work) {
lines.push(`### ${cellLabel(cell.team)}`, cell.summary);
if (cell.items?.length) lines.push(...cell.items.map((i) => `- ${i}`));
lines.push("");
}
}
if (draft.notes?.length) {
lines.push("## Notes", ...draft.notes.map((n) => `- ${n}`), "");
}
if (draft.acceptance_criteria.length) {
lines.push(
"## Success Criteria",
...draft.acceptance_criteria.map((c) => `- ${c}`),
""
);
}
return lines.join("\n").trim();
}
export function DraftProposalCard({
draft,
onKeepChatting,
onOpenReview,
onStart,
isLaunching = false,
}: DraftProposalCardProps) {
const priorityLabel = PRIORITY_LABELS[draft.priority ?? 2] ?? "High";
const priorityLabel = PRIORITY_LABELS[draft.priority ?? 2] ?? "Medium";
const cells = draft.the_work ?? [];
// Distinct cells only: the_work has one entry per work item, so a cell with
// several items would otherwise show its badge repeated (Backend Backend …).
const distinctTeams = Array.from(new Set(cells.map((c) => c.team)));
return (
<Card className="border-primary/30 bg-primary/5">
@@ -33,7 +79,7 @@ export function DraftProposalCard({
<CardTitle className="text-sm font-semibold leading-tight">
{draft.title}
</CardTitle>
<div className="flex flex-wrap gap-1 shrink-0">
<div className="flex flex-wrap items-center gap-1 shrink-0">
{draft.team && (
<Badge variant="secondary" className="text-xs">
{draft.team}
@@ -47,23 +93,38 @@ export function DraftProposalCard({
{draft.task_type}
</Badge>
)}
<CopyButton value={draftToText(draft)} className="ml-0.5" />
</div>
</div>
</CardHeader>
<CardContent className="pb-3 space-y-3">
{/* Description excerpt */}
{draft.description && (
{/* Objective (falls back to a description excerpt) */}
{(draft.objective || draft.description) && (
<p className="text-sm text-muted-foreground line-clamp-3">
{draft.description}
{draft.objective || draft.description}
</p>
)}
{/* The Work — participating cells (distinct) */}
{distinctTeams.length > 0 && (
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-xs font-medium text-muted-foreground">
{distinctTeams.length > 1 ? "Board-led across" : "Cell:"}
</span>
{distinctTeams.map((team) => (
<Badge key={team} variant="outline" className="text-xs">
{cellLabel(team)}
</Badge>
))}
</div>
)}
{/* Acceptance criteria */}
{draft.acceptance_criteria.length > 0 && (
<div>
<p className="text-xs font-medium text-muted-foreground mb-1.5">
Acceptance criteria ({draft.acceptance_criteria.length})
Success criteria ({draft.acceptance_criteria.length})
</p>
<ul className="space-y-1">
{draft.acceptance_criteria.slice(0, 4).map((criterion, i) => (
@@ -84,23 +145,38 @@ export function DraftProposalCard({
)}
</CardContent>
<CardFooter className="gap-2 pt-0">
<CardFooter className="flex-wrap gap-2 pt-0">
<Button
variant="outline"
size="sm"
className="flex-1"
onClick={onKeepChatting}
disabled={isLaunching}
>
<MessageCircle className="mr-1.5 h-3.5 w-3.5" />
Keep Chatting
Keep chatting
</Button>
{/* Board review & Start → PENDING, assigned to PO + HoM for review */}
<Button
variant="secondary"
size="sm"
className="flex-1"
onClick={onOpenReview}
onClick={() => onStart("board")}
disabled={isLaunching}
>
<ClipboardCheck className="mr-1.5 h-3.5 w-3.5" />
Review &amp; Confirm
{isLaunching ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Users className="mr-1.5 h-3.5 w-3.5" />
)}
Board review &amp; Start
</Button>
{/* Approve & Start → PENDING, straight to Main PM (skip the board) */}
<Button size="sm" onClick={() => onStart("main_pm")} disabled={isLaunching}>
{isLaunching ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Rocket className="mr-1.5 h-3.5 w-3.5" />
)}
Approve &amp; Start
</Button>
</CardFooter>
</Card>
+1 -1
View File
@@ -1,5 +1,5 @@
export { ChatMessages } from "./chat-messages";
export { ChatComposer } from "./chat-composer";
export { DraftProposalCard } from "./draft-proposal-card";
export { ConfirmDialog } from "./confirm-dialog";
export { SuccessCard } from "./success-card";
export { IntakeForm } from "./intake-form";
@@ -0,0 +1,166 @@
"use client";
import { Loader2, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useProjects } from "@/hooks/use-projects";
import { useProducts } from "@/hooks/use-products";
import type { TargetKind } from "@/hooks/use-prompter";
interface IntakeFormProps {
targetKind: TargetKind;
onTargetKind: (k: TargetKind) => void;
projectId: string;
onProjectId: (id: string) => void;
productId: string;
onProductId: (id: string) => void;
initialMessage: string;
onInitialMessage: (v: string) => void;
isValid: boolean;
isPreparing: boolean;
onStart: () => void;
}
/**
* The one-time scope form shown before the chat. The agent is spawned against
* exactly one of project / product, clones that scope's repo(s), and reads the
* real code before answering — so the scope must be chosen up front.
*/
export function IntakeForm({
targetKind,
onTargetKind,
projectId,
onProjectId,
productId,
onProductId,
initialMessage,
onInitialMessage,
isValid,
isPreparing,
onStart,
}: IntakeFormProps) {
const { data: projects = [] } = useProjects();
const { data: products = [] } = useProducts();
return (
<div className="flex flex-1 items-center justify-center px-6 py-8">
<div className="w-full max-w-lg space-y-6 rounded-xl border bg-card p-6 shadow-sm">
<div className="flex items-start gap-3">
<Sparkles className="mt-0.5 h-5 w-5 shrink-0 text-primary" />
<div>
<h2 className="text-base font-semibold">Start an intake chat</h2>
<p className="text-sm text-muted-foreground">
Pick what you&apos;re working on. An agent reads that code, then
interviews you and drafts the task.
</p>
</div>
</div>
{/* Scope: single-cell project vs board-led product */}
<div className="space-y-2">
<Label>
Scope <span className="text-destructive">*</span>
</Label>
<Tabs
value={targetKind}
onValueChange={(v) => onTargetKind(v as TargetKind)}
>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="project" disabled={isPreparing}>
Single cell (Project)
</TabsTrigger>
<TabsTrigger value="product" disabled={isPreparing}>
Board-led (Product)
</TabsTrigger>
</TabsList>
</Tabs>
{targetKind === "project" ? (
<Select
value={projectId}
onValueChange={onProjectId}
disabled={isPreparing}
>
<SelectTrigger>
<SelectValue placeholder="Select a project…" />
</SelectTrigger>
<SelectContent>
{projects.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<>
<Select
value={productId}
onValueChange={onProductId}
disabled={isPreparing}
>
<SelectTrigger>
<SelectValue placeholder="Select a product…" />
</SelectTrigger>
<SelectContent>
{products.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name} ({p.cell_count} cells)
</SelectItem>
))}
</SelectContent>
</Select>
{products.length === 0 && (
<p className="text-xs text-muted-foreground">
No products exist yet. A board-led feature needs a product (a
cellrepo map) create one under Products, or target a single
project instead.
</p>
)}
</>
)}
</div>
{/* Opening message */}
<div className="space-y-1.5">
<Label htmlFor="intake-initial-message">
What do you want to build?{" "}
<span className="text-destructive">*</span>
</Label>
<Textarea
id="intake-initial-message"
value={initialMessage}
onChange={(e) => onInitialMessage(e.target.value)}
placeholder="Describe the idea. The agent will read the code and ask sharp follow-ups…"
rows={4}
disabled={isPreparing}
/>
</div>
<Button
className="w-full"
onClick={onStart}
disabled={!isValid || isPreparing}
>
{isPreparing ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Preparing the agent
</>
) : (
"Start chatting"
)}
</Button>
</div>
</div>
);
}
+83
View File
@@ -0,0 +1,83 @@
"use client";
import { useState } from "react";
import { Copy, Check } from "lucide-react";
import { cn } from "@/lib/utils";
/**
* Put `text` on the clipboard.
*
* Tries the async Clipboard API first, then falls back to a hidden
* textarea + `execCommand("copy")`. The fallback matters: the panel is served
* over plain http on a LAN IP, and `navigator.clipboard` only exists in a
* secure context (https / localhost) — so on the real deployment the modern
* API is simply absent and the legacy path is what actually works.
*/
async function writeClipboard(text: string): Promise<boolean> {
if (
typeof navigator !== "undefined" &&
navigator.clipboard &&
window.isSecureContext
) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
// fall through to the legacy path
}
}
try {
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.left = "-9999px";
ta.setAttribute("readonly", "");
document.body.appendChild(ta);
ta.select();
const ok = document.execCommand("copy");
document.body.removeChild(ta);
return ok;
} catch {
return false;
}
}
interface CopyButtonProps {
/** The text placed on the clipboard. */
value: string;
/** Visible label next to the icon; omit for an icon-only button. */
label?: string;
className?: string;
}
/** A small copy-to-clipboard button that flips to a check for ~1.5s on success. */
export function CopyButton({ value, label, className }: CopyButtonProps) {
const [copied, setCopied] = useState(false);
const onCopy = async () => {
if (await writeClipboard(value)) {
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
}
};
return (
<button
type="button"
onClick={onCopy}
aria-label={label ?? "Copy"}
title={label ?? "Copy"}
className={cn(
"inline-flex shrink-0 items-center gap-1 rounded-md px-1.5 py-0.5 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground",
className
)}
>
{copied ? (
<Check className="h-3.5 w-3.5" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
{label ? <span>{copied ? "Copied" : label}</span> : null}
</button>
);
}
+403 -124
View File
@@ -1,19 +1,31 @@
"use client";
import { useState, useCallback, useRef } from "react";
import { useState, useCallback, useRef, useEffect } from "react";
import { toast } from "sonner";
import { prompterApi, type DraftProposal } from "@/lib/api/prompter";
import {
prompterLiveApi,
LIVE_EVENT_KINDS,
type LiveEvent,
} from "@/lib/api/prompter-live";
import {
type DraftProposal,
type CellWork,
type DraftScale,
type ConfirmPayload,
} from "@/lib/api/prompter";
import { getErrorMessage } from "@/lib/api/client";
import { useCreateTask } from "@/hooks/use-tasks";
import type { TaskCreate, Team, TaskType, Complexity } from "@/types";
import { Team } from "@/types";
import type { TaskType, TaskNature, Complexity } from "@/types";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type PrompterState =
| "empty"
| "form" // collecting scope + opening message (no chat yet)
| "preparing" // agent spawning / cloning the repo(s)
| "chatting"
| "streaming" // a reply is mid-flight over SSE
| "draft_preview"
| "review_modal"
| "launching"
@@ -29,6 +41,12 @@ export interface ChatMessage {
draft?: DraftProposal;
}
/** Which target the human picked for this chat. */
export type TargetKind = "project" | "product";
/** Which start button the human pressed on the draft card. */
export type StartRoute = "board" | "main_pm";
export interface EditableDraft {
title: string;
description: string;
@@ -36,7 +54,92 @@ export interface EditableDraft {
team: Team | "";
priority: number;
task_type: TaskType | "";
nature: TaskNature | "";
estimated_complexity: Complexity | "";
// Structured spec fields
objective: string;
what_this_builds: string[];
the_work: CellWork[];
notes: string[];
// Targeting
targetKind: TargetKind;
projectId: string;
productId: string;
}
const EMPTY_DRAFT: EditableDraft = {
title: "",
description: "",
acceptance_criteria: [],
team: "",
priority: 2,
task_type: "",
nature: "",
estimated_complexity: "",
objective: "",
what_this_builds: [],
the_work: [],
notes: [],
targetKind: "project",
projectId: "",
productId: "",
};
function newId(): string {
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
}
/** Remove the fenced ```roboco-draft block from displayed text — the structured
* draft card renders it; the raw JSON shouldn't sit in the chat bubble. */
function stripDraftFence(text: string): string {
return text.replace(/```roboco-draft[\s\S]*?```/g, "").trimEnd();
}
/** Map an agent-proposed draft (the `draft` SSE event payload) to the editable
* form, carrying the chat's chosen scope through unchanged. */
function toEditable(
draft: DraftProposal,
scale: DraftScale | null,
scope: { targetKind: TargetKind; projectId: string; productId: string }
): EditableDraft {
return {
title: draft.title,
// The prompter's propose_draft schema has NO `description` field — it uses
// `objective` + the structured spec. Fall back to objective so launch
// validation never reads `undefined` (which threw on `.trim()` and silently
// killed the button click).
description: draft.description ?? draft.objective ?? "",
acceptance_criteria: draft.acceptance_criteria,
team: draft.team ?? "",
priority: draft.priority ?? 2,
task_type: draft.task_type ?? "",
nature: draft.nature ?? "",
estimated_complexity: draft.estimated_complexity ?? "",
objective: draft.objective ?? "",
what_this_builds: draft.what_this_builds ?? [],
the_work: draft.the_work ?? [],
notes: draft.notes ?? [],
// The scope picked up front wins; fall back to scale only if unset.
targetKind:
scope.targetKind || (scale === "multi" ? "product" : "project"),
projectId: scope.projectId,
productId: scope.productId,
};
}
/** Pull a DraftProposal out of a `draft` SSE event's data payload. */
function draftFromEvent(data: Record<string, unknown> | undefined): {
draft: DraftProposal;
scale: DraftScale | null;
} | null {
if (!data || typeof data !== "object") return null;
const d = data as Record<string, unknown>;
if (typeof d.title !== "string") return null;
const scale =
d.scale === "single" || d.scale === "multi"
? (d.scale as DraftScale)
: null;
return { draft: d as unknown as DraftProposal, scale };
}
// ---------------------------------------------------------------------------
@@ -44,103 +147,236 @@ export interface EditableDraft {
// ---------------------------------------------------------------------------
export function usePrompter() {
const createTask = useCreateTask();
const [state, setState] = useState<PrompterState>("empty");
const [state, setState] = useState<PrompterState>("form");
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [sessionId, setSessionId] = useState<string | null>(null);
const [isSending, setIsSending] = useState(false);
const [isLaunching, setIsLaunching] = useState(false);
/** The latest tool the agent is using — "watch it work" status line. */
const [activity, setActivity] = useState<string | null>(null);
const [createdTaskId, setCreatedTaskId] = useState<string | null>(null);
const [createdTaskTitle, setCreatedTaskTitle] = useState<string | null>(null);
const [createdTaskTeam, setCreatedTaskTeam] = useState<Team | null>(null);
/** Draft as shown in the draft-preview card */
const [draftProposal, setDraftProposal] = useState<DraftProposal | null>(null);
// The up-front scope form.
const [targetKind, setTargetKind] = useState<TargetKind>("project");
const [projectId, setProjectId] = useState("");
const [productId, setProductId] = useState("");
const [initialMessage, setInitialMessage] = useState("");
/** Editable copy used in the confirmation dialog */
const [editableDraft, setEditableDraft] = useState<EditableDraft>({
title: "",
description: "",
acceptance_criteria: [],
team: "",
priority: 2,
task_type: "",
estimated_complexity: "",
});
const [editableDraft, setEditableDraft] = useState<EditableDraft>(EMPTY_DRAFT);
// Keep a ref to sessionId for callbacks to avoid stale closures
// Live-session plumbing held in refs so SSE callbacks never see stale state.
const sessionIdRef = useRef<string | null>(null);
const sourceRef = useRef<EventSource | null>(null);
const streamingIdRef = useRef<string | null>(null);
// Synchronous re-entry guard for launch — a double-click was creating two tasks.
const launchingRef = useRef(false);
const scopeRef = useRef({ targetKind, projectId, productId });
scopeRef.current = { targetKind, projectId, productId };
// -----------------------------------------------------------------------
// Helpers
// Message helpers
// -----------------------------------------------------------------------
const addMessage = useCallback((msg: Omit<ChatMessage, "id">) => {
const id = `msg-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
const id = newId();
setMessages((prev) => [...prev, { ...msg, id }]);
return id;
}, []);
/** Append a streamed token delta to the in-flight assistant message,
* starting a fresh one if this is the first delta of the turn. */
const appendDelta = useCallback((delta: string) => {
setMessages((prev) => {
const id = streamingIdRef.current;
if (id) {
return prev.map((m) =>
m.id === id ? { ...m, content: m.content + delta } : m
);
}
const newMsgId = newId();
streamingIdRef.current = newMsgId;
return [...prev, { id: newMsgId, role: "assistant", content: delta }];
});
}, []);
/** Attach the agent's proposed draft to the current/last assistant message,
* stripping the raw draft block out of that message's displayed text. */
const attachDraft = useCallback((draft: DraftProposal) => {
setMessages((prev) => {
// Attach ONLY to the CURRENT turn's streaming message. Do NOT fall back to
// "the last assistant message anywhere" — that can be a PRIOR turn's message
// sitting above the user's latest message, which made the draft card render
// above the user's "Yes, propose it". When there's no current streaming
// message, append a fresh one so the card always lands at the bottom.
const id = streamingIdRef.current;
if (id) {
return prev.map((m) =>
m.id === id
? { ...m, draft, content: stripDraftFence(m.content) }
: m
);
}
return [...prev, { id: newId(), role: "assistant", content: "", draft }];
});
}, []);
// -----------------------------------------------------------------------
// SSE handling
// -----------------------------------------------------------------------
const handleEvent = useCallback(
(evt: LiveEvent) => {
switch (evt.kind) {
case "text":
if (evt.text) {
setActivity(null); // first text clears the "preparing…" indicator
appendDelta(evt.text);
setState("streaming");
}
break;
case "tool_use":
// A tool call ends the current text bubble, so the agent's words
// before and after the tool render as separate messages (fixes
// "two waves merged into one big bubble").
streamingIdRef.current = null;
setActivity(evt.tool ? `Using ${evt.tool}` : "Working…");
break;
case "thinking":
setActivity("Thinking…");
break;
case "turn_end":
streamingIdRef.current = null;
setActivity(null);
setIsSending(false);
setState((s) => (s === "draft_preview" ? s : "chatting"));
break;
case "draft": {
const parsed = draftFromEvent(evt.data);
if (parsed) {
attachDraft(parsed.draft);
setEditableDraft(
toEditable(parsed.draft, parsed.scale, scopeRef.current)
);
setState("draft_preview");
}
break;
}
case "error":
streamingIdRef.current = null;
setActivity(null);
setIsSending(false);
addMessage({
role: "error",
content: evt.text || "The agent hit an error.",
});
setState("chatting");
break;
// "system" / "tool_result" are informational — ignored in the UI.
default:
break;
}
},
[appendDelta, attachDraft, addMessage]
);
const closeStream = useCallback(() => {
sourceRef.current?.close();
sourceRef.current = null;
}, []);
const openStream = useCallback(
(sid: string) => {
closeStream();
const es = new EventSource(prompterLiveApi.streamUrl(sid));
for (const kind of LIVE_EVENT_KINDS) {
es.addEventListener(kind, (e: MessageEvent) => {
try {
handleEvent(JSON.parse(e.data) as LiveEvent);
} catch {
// A malformed frame is dropped; the stream stays open.
}
});
}
sourceRef.current = es;
},
[closeStream, handleEvent]
);
// Best-effort reap if the user navigates away mid-chat.
useEffect(() => {
return () => {
closeStream();
const sid = sessionIdRef.current;
if (sid) void prompterLiveApi.stop(sid).catch(() => undefined);
};
}, [closeStream]);
// -----------------------------------------------------------------------
// Start the live session (from the scope form)
// -----------------------------------------------------------------------
const isFormValid = useCallback((): boolean => {
const scoped = targetKind === "product" ? productId !== "" : projectId !== "";
return scoped && initialMessage.trim().length > 0;
}, [targetKind, projectId, productId, initialMessage]);
const start = useCallback(async () => {
if (!isFormValid() || state === "preparing") return;
const opening = initialMessage.trim();
setState("preparing");
addMessage({ role: "user", content: opening });
try {
const { session_id } = await prompterLiveApi.start({
...(targetKind === "product"
? { product_id: productId }
: { project_id: projectId }),
initial_message: opening,
});
sessionIdRef.current = session_id;
setSessionId(session_id);
openStream(session_id);
setIsSending(true); // the opening reply is on its way over SSE
// start now returns immediately; the container spawns in the background
// (clone + image build can take a minute). Show that until the first event.
setActivity("Preparing the agent — cloning your repo and reading the code…");
setState("streaming");
} catch (err) {
addMessage({ role: "error", content: getErrorMessage(err) });
setState("form");
}
}, [
isFormValid,
state,
initialMessage,
targetKind,
productId,
projectId,
addMessage,
openStream,
]);
// -----------------------------------------------------------------------
// Send a chat message
// -----------------------------------------------------------------------
const send = useCallback(
async (text: string) => {
if (!text.trim() || isSending) return;
const trimmed = text.trim();
const sid = sessionIdRef.current;
if (!trimmed || isSending || !sid) return;
setIsSending(true);
setState("chatting");
// Add user message to chat
addMessage({ role: "user", content: text.trim() });
setState("streaming");
addMessage({ role: "user", content: trimmed });
try {
let sid = sessionIdRef.current;
// Create session on first message
if (!sid) {
const { session_id } = await prompterApi.createSession();
sid = session_id;
sessionIdRef.current = sid;
setSessionId(sid);
}
// Send message and get reply
const response = await prompterApi.sendMessage(sid, text.trim());
if (response.draft) {
// LLM produced a draft — add assistant message with embedded draft
addMessage({
role: "assistant",
content: response.reply,
draft: response.draft,
});
setDraftProposal(response.draft);
setEditableDraft({
title: response.draft.title,
description: response.draft.description,
acceptance_criteria: response.draft.acceptance_criteria,
team: response.draft.team ?? "",
priority: response.draft.priority ?? 2,
task_type: response.draft.task_type ?? "",
estimated_complexity: response.draft.estimated_complexity ?? "",
});
setState("draft_preview");
} else {
// Plain text reply
addMessage({ role: "assistant", content: response.reply });
setState("chatting");
}
await prompterLiveApi.sendMessage(sid, trimmed);
// The reply streams back over SSE; isSending clears on turn_end.
} catch (err) {
const msg = getErrorMessage(err);
addMessage({
role: "error",
content: msg,
});
setState("chatting");
} finally {
setIsSending(false);
addMessage({ role: "error", content: getErrorMessage(err) });
setState("chatting");
}
},
[isSending, addMessage]
@@ -150,93 +386,124 @@ export function usePrompter() {
// Review & Confirm actions
// -----------------------------------------------------------------------
const openReview = useCallback(() => {
setState("review_modal");
}, []);
const closeReview = useCallback(() => {
setState("draft_preview");
}, []);
const keepChatting = useCallback(() => {
setState("chatting");
}, []);
const openReview = useCallback(() => setState("review_modal"), []);
const closeReview = useCallback(() => setState("draft_preview"), []);
const keepChatting = useCallback(() => setState("chatting"), []);
const updateDraft = useCallback((updates: Partial<EditableDraft>) => {
setEditableDraft((prev) => ({ ...prev, ...updates }));
}, []);
// -----------------------------------------------------------------------
// Validation
// -----------------------------------------------------------------------
const isValidForLaunch = useCallback((): boolean => {
return (
const base =
editableDraft.title.trim().length > 0 &&
editableDraft.description.trim().length >= 20 &&
editableDraft.acceptance_criteria.length > 0 &&
editableDraft.team !== ""
);
(editableDraft.description ?? "").trim().length >= 20 &&
editableDraft.acceptance_criteria.length > 0;
const targeted =
editableDraft.targetKind === "product"
? editableDraft.productId !== ""
: editableDraft.projectId !== "" && editableDraft.team !== "";
return base && targeted;
}, [editableDraft]);
// -----------------------------------------------------------------------
// Launch (create task)
// Launch — confirm the draft → task, then reap the agent
// -----------------------------------------------------------------------
const launchTask = useCallback(async () => {
if (!isValidForLaunch()) return;
const launchTask = useCallback(async (route: StartRoute) => {
// Re-entry guard FIRST (synchronous, no stale closure): a double-click was
// firing two confirms and creating duplicate tasks.
if (launchingRef.current) return;
const sid = sessionIdRef.current;
// Never fail silently — a dead button with no feedback reads as "broken"
// (it did: a missing `description` threw inside validation and the click
// vanished). Tell the human exactly what's blocking the launch.
if (!sid) {
toast.error("This chat has ended — start a new one to launch a task.");
return;
}
if (!isValidForLaunch()) {
toast.error(
"The draft is missing something needed to launch: a title, a 20+ character " +
"summary, at least one acceptance criterion, and a target. Keep chatting to refine it."
);
return;
}
launchingRef.current = true;
setIsLaunching(true);
setState("launching");
const payload: TaskCreate = {
const draft: DraftProposal = {
title: editableDraft.title.trim(),
description: editableDraft.description.trim(),
description: (editableDraft.description ?? "").trim(),
acceptance_criteria: editableDraft.acceptance_criteria,
team: editableDraft.team as Team,
priority: editableDraft.priority,
...(editableDraft.task_type ? { task_type: editableDraft.task_type as TaskType } : {}),
objective: editableDraft.objective.trim() || null,
what_this_builds: editableDraft.what_this_builds,
the_work: editableDraft.the_work,
notes: editableDraft.notes,
...(editableDraft.task_type ? { task_type: editableDraft.task_type } : {}),
...(editableDraft.nature ? { nature: editableDraft.nature } : {}),
...(editableDraft.estimated_complexity
? { estimated_complexity: editableDraft.estimated_complexity as Complexity }
? { estimated_complexity: editableDraft.estimated_complexity }
: {}),
};
const payload: ConfirmPayload =
editableDraft.targetKind === "product"
? { product_id: editableDraft.productId, draft, route }
: { project_id: editableDraft.projectId, draft, route };
const effectiveTeam =
editableDraft.targetKind === "product"
? Team.MAIN_PM
: (editableDraft.team as Team);
try {
const task = await createTask.mutateAsync(payload);
setCreatedTaskId(task.id);
setCreatedTaskTitle(task.title);
setCreatedTaskTeam(task.team as Team);
toast.success("Task created successfully!");
const { task_id } = await prompterLiveApi.confirm(sid, payload);
// The draft became a task — reap the agent and close the stream.
closeStream();
void prompterLiveApi.stop(sid).catch(() => undefined);
sessionIdRef.current = null;
setCreatedTaskId(task_id);
setCreatedTaskTitle(draft.title);
setCreatedTaskTeam(effectiveTeam);
toast.success("Task created and launched!");
setState("success");
} catch (err) {
const msg = getErrorMessage(err);
toast.error(`Failed to create task: ${msg}`);
setState("review_modal");
toast.error(`Failed to launch task: ${getErrorMessage(err)}`);
setState("draft_preview"); // back to the draft card to retry
} finally {
setIsLaunching(false);
launchingRef.current = false;
}
}, [editableDraft, isValidForLaunch, createTask]);
}, [editableDraft, isValidForLaunch, closeStream]);
// -----------------------------------------------------------------------
// Reset to start another conversation
// -----------------------------------------------------------------------
const startAnother = useCallback(() => {
closeStream();
const sid = sessionIdRef.current;
if (sid) void prompterLiveApi.stop(sid).catch(() => undefined);
sessionIdRef.current = null;
streamingIdRef.current = null;
setMessages([]);
setSessionId(null);
sessionIdRef.current = null;
setDraftProposal(null);
setEditableDraft({
title: "",
description: "",
acceptance_criteria: [],
team: "",
priority: 2,
task_type: "",
estimated_complexity: "",
});
setActivity(null);
setEditableDraft(EMPTY_DRAFT);
setProjectId("");
setProductId("");
setInitialMessage("");
setTargetKind("project");
setCreatedTaskId(null);
setCreatedTaskTitle(null);
setCreatedTaskTeam(null);
setState("empty");
}, []);
setState("form");
}, [closeStream]);
return {
// State
@@ -244,13 +511,25 @@ export function usePrompter() {
messages,
sessionId,
isSending,
draftProposal,
activity,
editableDraft,
createdTaskId,
createdTaskTitle,
createdTaskTeam,
// Actions
// Scope form
targetKind,
setTargetKind,
projectId,
setProjectId,
productId,
setProductId,
initialMessage,
setInitialMessage,
isFormValid,
start,
// Chat + confirm
send,
openReview,
closeReview,
@@ -259,6 +538,6 @@ export function usePrompter() {
isValidForLaunch,
launchTask,
startAnother,
isLaunching: createTask.isPending,
isLaunching,
};
}
+96
View File
@@ -0,0 +1,96 @@
import api, { API_URL } from "./client";
import type { ConfirmPayload } from "./prompter";
// ---------------------------------------------------------------------------
// Live intake chat — the panel side of the spawned-agent bridge.
//
// Unlike the legacy Ollama session API (`prompter.ts`), the brain here is a
// real spawned Claude Code agent. The panel:
// 1. POSTs /prompter/live/start with the scope (project XOR product) + the
// opening message → gets a session id (the agent is spawned, the opening
// message is delivered server-side once its container is reachable).
// 2. opens an SSE stream and watches the agent work (token deltas, tool
// calls), and renders the draft card when the agent proposes one.
// 3. POSTs each subsequent message to /messages; replies arrive over SSE.
// 4. on confirm, /confirm turns the draft into a task and reaps the agent.
// ---------------------------------------------------------------------------
/** Open a live chat scoped to exactly one of project / product. */
export interface StartLivePayload {
project_id?: string;
product_id?: string;
initial_message?: string;
}
export interface StartLiveResponse {
session_id: string;
}
/** Event kinds the container relays — mirrors the backend driver.StreamChunk. */
export type LiveEventKind =
| "text"
| "thinking"
| "tool_use"
| "tool_result"
| "turn_end"
| "system"
| "draft"
| "error";
/** One normalized event from the agent's live reply. */
export interface LiveEvent {
kind: LiveEventKind;
text?: string;
tool?: string;
data?: Record<string, unknown>;
}
/** Every named SSE event we subscribe to; each carries the full LiveEvent as JSON. */
export const LIVE_EVENT_KINDS: LiveEventKind[] = [
"text",
"thinking",
"tool_use",
"tool_result",
"turn_end",
"system",
"draft",
"error",
];
export const prompterLiveApi = {
/** Spawn the intake agent for a new chat. */
start: async (payload: StartLivePayload): Promise<StartLiveResponse> => {
const { data } = await api.post<StartLiveResponse>(
"/prompter/live/start",
payload
);
return data;
},
/** SSE URL the panel opens to watch the agent. EventSource sends no headers
* (the route is keyed by the opaque session id on the trusted network). */
streamUrl: (sessionId: string): string =>
`${API_URL}/prompter/live/${sessionId}/stream`,
/** Deliver the human's message to the running agent; the reply streams back. */
sendMessage: async (sessionId: string, text: string): Promise<void> => {
await api.post(`/prompter/live/${sessionId}/messages`, { text });
},
/** Reap the session (draft confirmed, or the human left the page). */
stop: async (sessionId: string): Promise<void> => {
await api.post(`/prompter/live/${sessionId}/stop`);
},
/** Confirm the draft → create the task and reap the agent (Phase 4 backend). */
confirm: async (
sessionId: string,
payload: ConfirmPayload
): Promise<{ task_id: string }> => {
const { data } = await api.post<{ task_id: string }>(
`/prompter/live/${sessionId}/confirm`,
payload
);
return data;
},
};
+32 -94
View File
@@ -1,10 +1,20 @@
import api from "./client";
import type { Team, TaskType, Complexity } from "@/types";
import type { Team, TaskType, TaskNature, Complexity } from "@/types";
// ---------------------------------------------------------------------------
// Types
// Prompter draft types — shared by the live intake hook (`prompter-live.ts`),
// the draft card, and the confirm dialog. The chat itself is driven by the
// spawned agent over SSE (`prompter-live.ts`); these are just the shapes of
// the structured draft the agent proposes and the human confirms.
// ---------------------------------------------------------------------------
/** One cell's slice of the work — the per-cell breakdown of The Work. */
export interface CellWork {
team: Team;
summary: string;
items: string[];
}
/** A structured task draft, mirroring the backend PrompterDraftTask. */
export interface DraftProposal {
title: string;
description: string;
@@ -12,98 +22,26 @@ export interface DraftProposal {
team: Team;
priority?: number;
task_type?: TaskType;
nature?: TaskNature;
estimated_complexity?: Complexity;
// Structured spec fields
objective?: string | null;
what_this_builds?: string[];
the_work?: CellWork[];
notes?: string[];
// Targeting (resolved at confirm time)
project_id?: string | null;
product_id?: string | null;
}
export interface ChatResponse {
reply: string;
draft?: DraftProposal | null;
session_id: string;
/** Single-cell project vs board-led multi-cell product. */
export type DraftScale = "single" | "multi";
/** What the human picked/edited at confirm time. `route` is which start button:
* "board" (Board review & Start) or "main_pm" (Approve & Start). */
export interface ConfirmPayload {
project_id?: string;
product_id?: string;
draft?: DraftProposal;
route?: "board" | "main_pm";
}
export interface CreateSessionResponse {
session_id: string;
}
// A message record as returned by the backend (PrompterMessageResponse).
interface BackendMessage {
id: string;
session_id: string;
role: "user" | "assistant";
content: string;
created_at: string;
}
// Mirrors the backend's draft-ready signal phrases (services/prompter.py).
// If the backend list ever drifts, the draft simply doesn't auto-surface (the
// user can keep chatting) — it never breaks the conversation flow.
const DRAFT_READY_SIGNALS = [
"i have enough information",
"ready to generate a draft",
"ready to draft",
"i can now draft",
"draft_ready=true",
"draft ready",
];
function replyLooksDraftReady(reply: string): boolean {
const lower = reply.toLowerCase();
return DRAFT_READY_SIGNALS.some((s) => lower.includes(s));
}
// ---------------------------------------------------------------------------
// API functions
// ---------------------------------------------------------------------------
export const prompterApi = {
/**
* Create a new prompter session, returning a session ID.
* The endpoint requires a JSON body (optional `context`), so send `{}`, and
* map the backend's `id` field onto our `session_id`.
*/
createSession: async (): Promise<CreateSessionResponse> => {
const { data } = await api.post<{ id: string }>("/prompter/sessions", {});
return { session_id: data.id };
},
/**
* Send a chat message in an existing session. The backend appends the user
* message, replies, and returns the full message list. We surface the latest
* assistant message as the reply and, when it signals readiness, fetch the
* structured draft.
*/
sendMessage: async (
sessionId: string,
message: string
): Promise<ChatResponse> => {
const { data: messages } = await api.post<BackendMessage[]>(
`/prompter/sessions/${sessionId}/messages`,
{ content: message }
);
const lastAssistant = [...messages]
.reverse()
.find((m) => m.role === "assistant");
const reply = lastAssistant?.content ?? "";
let draft: DraftProposal | null = null;
if (replyLooksDraftReady(reply)) {
try {
draft = await prompterApi.getDraft(sessionId);
} catch {
draft = null;
}
}
return { reply, draft, session_id: sessionId };
},
/**
* Fetch the current draft for a session (if the LLM has produced one).
* The backend returns a TaskDraftResponse whose `draft` field holds the task.
*/
getDraft: async (sessionId: string): Promise<DraftProposal | null> => {
const { data } = await api.get<{ draft: DraftProposal | null }>(
`/prompter/sessions/${sessionId}/draft`
);
return data.draft;
},
};
+6 -6
View File
@@ -21,11 +21,9 @@ dependencies = [
"sqlalchemy[asyncio]",
"asyncpg", # PostgreSQL async driver
"alembic", # Migrations
# Cache/Queue
"redis",
"hiredis", # Redis performance
# RAG (piragi with PostgreSQL/pgvector backend)
"piragi[postgres]",
# AI/LLM
@@ -33,7 +31,6 @@ dependencies = [
"openai", # For embeddings
"tiktoken", # Token counting
"python-toon", # Token-efficient LLM serialization
# MCP (Model Context Protocol)
"mcp",
# Utilities
@@ -43,19 +40,17 @@ dependencies = [
"passlib[bcrypt]", # Password hashing
"tenacity", # Retry logic
"structlog", # Structured logging
# Streaming
"sse-starlette", # Server-Sent Events for A2A streaming
# Direct imports (promoted from transitive)
"cryptography", # utils/crypto.py — Fernet-encrypted project git tokens
# Build-time pin (not imported). Verified empirically: removing this
# entry causes uv to ignore the [tool.uv.sources.torch] CPU-only
# redirect and pull ~2.5GB of unused CUDA wheels. The entry has to
# appear in direct deps for the source override to bind. deptry's
# DEP002 ignore for `torch` below documents the same reality.
"torch",
"claude-agent-sdk>=0.2.94",
]
[project.optional-dependencies]
@@ -181,6 +176,10 @@ select = [
"roboco/services/gateway/**/*.py" = ["PLC0415", "PLR0913"]
"roboco/api/routes/*.py" = ["PLC0415"]
"roboco/runtime/*.py" = ["PLC0415"]
# The intake driver/entrypoint lazily import the heavy `claude-agent-sdk` (and
# uvicorn) so the modules import without those installed and don't pay the cost
# until a live container runs them — same rationale as the dirs above.
"roboco/agent_sdk/*.py" = ["PLC0415"]
# Lifecycle validators: foundation/_validate_lifecycle is imported from the
# bottom of policy/lifecycle.py at module-load time, so the validators must
# defer their inverse imports until call time to avoid a cycle.
@@ -218,6 +217,7 @@ module = [
"toon.*",
"sse_starlette.*",
"asyncpg.*",
"claude_agent_sdk.*", # third-party SDK, ships no type stubs
]
ignore_missing_imports = true
+402
View File
@@ -0,0 +1,402 @@
"""Intake agent driver — a long-lived Claude Code session the human chats with.
The intake (``prompter``) agent is not a one-shot ``claude -p`` like every other
RoboCo agent; it is an interactive session. This driver is the container's
entrypoint: it opens ONE ``claude-agent-sdk`` ``ClaudeSDKClient`` (Claude Code
held open), then loops — pull the human's next message, stream the agent's reply
(token deltas, tool calls), wait for the next message — keeping conversation
context in-process. The container stays alive for the whole chat and is reaped
when the draft becomes a task.
The SDK call surface is isolated in ``SdkIntakeSession`` (lazy import, so this
module imports without ``claude-agent-sdk`` installed). The loop
(``IntakeDriver``) and event normalization are SDK-free and unit-tested with
fakes.
"""
from __future__ import annotations
import json
import re
from collections.abc import AsyncIterator, Awaitable, Callable
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Protocol
import structlog
if TYPE_CHECKING:
from contextlib import AbstractAsyncContextManager
logger = structlog.get_logger()
# The intake agent emits the finished structured task draft as a fenced block
# (see the prompter system prompt). The driver mines it from the complete reply
# and surfaces it as one ``draft`` chunk for the panel's draft card.
_DRAFT_FENCE = re.compile(r"```roboco-draft\s*\n(.*?)```", re.DOTALL)
# ---------------------------------------------------------------------------
# Normalized stream chunk — what the panel SSE consumes. SDK-free.
# ---------------------------------------------------------------------------
@dataclass
class StreamChunk:
"""One normalized event in the agent's live reply.
``kind`` is the panel-facing event type; the rest is payload. Decoupled
from the SDK's message classes so the relay/panel never import the SDK.
"""
kind: str # text|thinking|tool_use|tool_result|turn_end|system|draft|error
text: str = ""
tool: str = ""
data: dict[str, Any] = field(default_factory=dict)
def _coerce_draft(data: Any) -> dict[str, Any] | None:
"""Return ``data`` as a draft dict (with a string ``title``), else ``None``.
Accepts a dict, or a JSON string the agent may have passed.
"""
if isinstance(data, str):
try:
data = json.loads(data)
except (ValueError, TypeError):
return None
if isinstance(data, dict) and isinstance(data.get("title"), str):
return data
return None
def _extract_draft(text: str) -> dict[str, Any] | None:
"""Parse a fenced ``roboco-draft`` JSON block out of the agent's reply.
A fallback to the ``propose_draft`` tool: returns the parsed object (a dict
with a string ``title``) or ``None`` when no well-formed block is present.
"""
match = _DRAFT_FENCE.search(text)
if match is None:
return None
return _coerce_draft(match.group(1))
def _draft_from_tool_input(tool_input: Any) -> dict[str, Any] | None:
"""Pull the draft out of a ``propose_draft`` tool call's input.
Tolerant of both shapes the agent might use: the draft nested under a
``draft`` key, or the draft fields passed flat as the input itself.
"""
if not isinstance(tool_input, dict):
return None
return _coerce_draft(tool_input.get("draft", tool_input))
def _is_propose_draft(name: str) -> bool:
"""True for the intake ``propose_draft`` tool, however the SDK namespaces it."""
return name == "propose_draft" or name.endswith("__propose_draft")
def _blocks_to_chunks(content: list[Any]) -> list[StreamChunk]:
"""Map an assistant message's content blocks to chunks (duck-typed).
Text is deliberately NOT re-emitted here: with ``include_partial_messages``
the live token deltas (``StreamEvent``) already streamed it, so re-emitting
the complete ``TextBlock`` would render every reply twice on the panel.
The canonical draft signal is the agent calling the **``propose_draft``**
tool — that ToolUseBlock becomes a single ``draft`` chunk. As a fallback (if
the agent types the spec instead of calling the tool) the complete text is
also mined for a fenced ``roboco-draft`` block. thinking + other tool_use
(which do NOT arrive as deltas) are emitted as before.
"""
chunks: list[StreamChunk] = []
text_parts: list[str] = []
draft: dict[str, Any] | None = None
for block in content or []:
if hasattr(block, "thinking"): # ThinkingBlock
chunks.append(StreamChunk(kind="thinking", text=str(block.thinking)))
elif hasattr(block, "name") and hasattr(block, "input"): # ToolUseBlock
name = str(block.name)
tool_input = getattr(block, "input", {})
if _is_propose_draft(name):
draft = draft or _draft_from_tool_input(tool_input)
else:
chunks.append(
StreamChunk(kind="tool_use", tool=name, data={"input": tool_input})
)
elif hasattr(block, "text"): # TextBlock — already streamed; mine for a draft
text_parts.append(str(block.text))
draft = draft or _extract_draft("".join(text_parts))
if draft is not None:
chunks.append(StreamChunk(kind="draft", data=draft))
return chunks
def _stream_event_to_chunks(msg: Any) -> list[StreamChunk]:
"""Extract a live text delta from a partial StreamEvent (token streaming)."""
event = getattr(msg, "event", None) or {}
delta = event.get("delta") if isinstance(event, dict) else None
if isinstance(delta, dict) and delta.get("type") == "text_delta":
text = str(delta.get("text", ""))
if text:
return [StreamChunk(kind="text", text=text)]
return []
def normalize(msg: Any) -> list[StreamChunk]:
"""Map a single ``claude-agent-sdk`` message to panel-facing chunks.
Duck-typed on type name + attributes so it works on real SDK messages and
on test fakes alike (no SDK import required).
"""
name = type(msg).__name__
if name == "StreamEvent":
return _stream_event_to_chunks(msg)
if name == "AssistantMessage":
return _blocks_to_chunks(getattr(msg, "content", []))
if name == "ResultMessage":
return [
StreamChunk(
kind="turn_end",
data={
"session_id": getattr(msg, "session_id", None),
"cost_usd": getattr(msg, "total_cost_usd", None),
},
)
]
if name == "SystemMessage":
return [
StreamChunk(kind="system", data={"subtype": getattr(msg, "subtype", "")})
]
return []
# ---------------------------------------------------------------------------
# Session seam — one conversational turn -> a stream of chunks.
# ---------------------------------------------------------------------------
class IntakeSession(Protocol):
"""A live agent session. ``send`` runs one turn and streams its chunks."""
def send(self, text: str) -> AsyncIterator[StreamChunk]: ...
# A factory that yields an async-context-managed IntakeSession (opens/closes
# the underlying client). Injected so the driver loop is testable with a fake.
SessionFactory = Callable[[], "AbstractAsyncContextManager[IntakeSession]"]
# Source of the human's messages (e.g. the in-container inbox). Returns None to
# signal shutdown (container being reaped).
MessageSource = Callable[[], Awaitable[str | None]]
# Where normalized chunks go (the relay -> panel SSE).
EventSink = Callable[[StreamChunk], Awaitable[None]]
# ---------------------------------------------------------------------------
# The driver loop — SDK-free, unit-tested with fakes.
# ---------------------------------------------------------------------------
class IntakeDriver:
"""Owns the chat loop for the lifetime of one intake session."""
def __init__(
self,
session_factory: SessionFactory,
next_message: MessageSource,
emit: EventSink,
) -> None:
self._session_factory = session_factory
self._next_message = next_message
self._emit = emit
self.log = logger.bind(component="intake_driver")
async def run(self) -> None:
"""Open the session and process human turns until shutdown.
One ``ClaudeSDKClient`` is held open across all turns (context persists
in-process). The loop ends when ``next_message`` returns ``None``.
"""
async with self._session_factory() as session:
self.log.info("Intake session opened")
turns = 0
while True:
text = await self._next_message()
if text is None:
self.log.info("Intake session closing", turns=turns)
return
turns += 1
self.log.info("Intake turn received", turn=turns, chars=len(text))
await self._run_turn(session, text)
async def _run_turn(self, session: IntakeSession, text: str) -> None:
"""Stream one turn's chunks to the sink, logging each tool call.
The conversation streams to the relay (panel), not stdout — so without
this, ``docker logs`` on the intake container is a black box between turn
start and end even while the agent reads the codebase and spawns subagents.
Logging each ``tool_use`` (and the draft) shows the turn's real shape;
text deltas are intentionally NOT logged (they'd spam). A failure ends as
an error chunk.
"""
chunks = 0
tools = 0
drafted = False
try:
async for chunk in session.send(text):
chunks += 1
if chunk.kind == "tool_use":
tools += 1
self.log.info("Intake tool use", tool=chunk.tool)
elif chunk.kind == "draft":
drafted = True
self.log.info("Intake draft emitted")
await self._emit(chunk)
except Exception as exc:
self.log.error("Intake turn failed", error=str(exc), chunks=chunks)
await self._emit(StreamChunk(kind="error", text=str(exc)))
else:
self.log.info(
"Intake turn streamed", chunks=chunks, tools=tools, drafted=drafted
)
# ---------------------------------------------------------------------------
# SDK adapter — the only SDK-coupled code (lazy import). Verified against
# claude-agent-sdk; not exercised in the gate (needs the live claude binary).
# ---------------------------------------------------------------------------
# The intake agent's hard tool allowlist: read-only built-ins + the draft tool.
_INTAKE_BASE_TOOLS: tuple[str, ...] = ("Read", "Grep", "Glob", "Task")
def build_intake_options(
*,
system_prompt: str,
cwd: str,
model: str | None = None,
) -> Any: # pragma: no cover - thin SDK construction
"""Build locked-down ``ClaudeAgentOptions`` for the intake session.
Isolation/security (smoke 2026-06-09 #11): the intake agent must NOT inherit
the host's personal Claude Code env (Gmail/Notion MCP, Write/Edit/Bash). So:
- ``strict_mcp_config=True`` + ``setting_sources=[]`` → ignore the host's
``~/.claude.json`` / ``settings.json``; use ONLY the MCP server below.
- ``permission_mode="dontAsk"`` (NOT ``bypassPermissions``) + a ``can_use_tool``
gate → a hard allowlist (Read/Grep/Glob/Task + ``propose_draft``), no prompts.
Draft emission (#10): the agent calls the ``propose_draft`` MCP tool, which the
driver turns into a ``draft`` event — deterministic, not a fragile text fence.
NOTE: ``setting_sources=[]`` must be validated against the mounted-``~/.claude``
auth on the next smoke; if auth breaks, narrow it instead of removing it.
"""
from claude_agent_sdk import (
ClaudeAgentOptions,
PermissionResultAllow,
PermissionResultDeny,
create_sdk_mcp_server,
tool,
)
@tool(
"propose_draft",
"Submit the finished task draft for the human to review and confirm. Call "
"this once the spec is complete. Pass a JSON object: title, objective, "
"what_this_builds[], the_work[] ({team, summary, items}), notes[], "
"acceptance_criteria[], team, scale, task_type, nature, "
"estimated_complexity, priority.",
{"draft": dict},
)
async def _propose_draft(_args: dict[str, Any]) -> dict[str, Any]:
# The driver intercepts this tool call (ToolUseBlock) and emits the draft
# event; the handler only acknowledges so the agent knows it landed.
return {
"content": [
{"type": "text", "text": "Draft submitted — the human can review it."}
]
}
server = create_sdk_mcp_server(
name="intake", version="1.0.0", tools=[_propose_draft]
)
async def _gate(tool_name: str, _input: dict[str, Any], _ctx: Any) -> Any:
if tool_name in _INTAKE_BASE_TOOLS or _is_propose_draft(tool_name):
return PermissionResultAllow()
# The intake's job is to ask questions, so it reaches for AskUserQuestion
# by reflex. It isn't wired to the live chat panel (and isn't allowed), so
# nudge it to just ask inline rather than leave it to stumble on a bare deny.
if tool_name == "AskUserQuestion" or tool_name.endswith("AskUserQuestion"):
return PermissionResultDeny(
message=(
"AskUserQuestion isn't available here — just write your "
"questions as a normal chat message; the human reads every "
"reply live."
)
)
# Plan mode is a Claude Code workflow the intake keeps slipping into; its
# "plan" is the propose_draft draft, so steer it straight there.
if tool_name == "ExitPlanMode" or tool_name.endswith("ExitPlanMode"):
return PermissionResultDeny(
message=(
"You don't use plan mode. When your spec is ready, call "
"propose_draft to produce the reviewable draft card — don't "
"announce a plan and wait."
)
)
# Generic deny, but guiding: the agent reflexively probes Claude Code
# built-ins (Write, ToolSearch, …). Tell it what it actually has.
return PermissionResultDeny(
message=(
f"{tool_name} is not available to the intake agent. Your only tools "
"are Read, Grep, Glob, Task, and propose_draft. Ask the human inline; "
"when the spec is ready, call propose_draft."
)
)
return ClaudeAgentOptions(
system_prompt=system_prompt,
cwd=cwd,
mcp_servers={"intake": server},
allowed_tools=[*_INTAKE_BASE_TOOLS, "mcp__intake__propose_draft"],
model=model,
include_partial_messages=True, # live token streaming
permission_mode="dontAsk",
strict_mcp_config=True,
setting_sources=[],
can_use_tool=_gate,
)
class SdkIntakeSession: # pragma: no cover - requires the live claude binary
"""``IntakeSession`` backed by a real ``ClaudeSDKClient``.
Async context manager: connects the client on enter, disconnects on exit.
``send`` runs one turn (query + receive_response) and yields normalized
chunks. The conversation context lives in the client across turns.
"""
def __init__(self, options: Any) -> None:
self._options = options
self._client: Any = None
async def __aenter__(self) -> SdkIntakeSession:
from claude_agent_sdk import ClaudeSDKClient
self._client = ClaudeSDKClient(options=self._options)
await self._client.connect()
return self
async def __aexit__(self, *exc: object) -> None:
if self._client is not None:
await self._client.disconnect()
async def send(self, text: str) -> AsyncIterator[StreamChunk]:
await self._client.query(text)
async for msg in self._client.receive_response():
for chunk in normalize(msg):
yield chunk
+163
View File
@@ -0,0 +1,163 @@
"""Container entrypoint for the intake (``prompter``) agent — the live session.
Runs as the agent-prompter container's command. Wires the ``IntakeDriver`` to:
- an in-process HTTP **receiver** (`POST /turn`) the orchestrator delivers the
human's messages to — this is the driver's ``MessageSource``;
- a **relay** ``EventSink`` that POSTs each ``StreamChunk`` to the
orchestrator's `/live/{session}/events` endpoint.
One long-lived ``ClaudeSDKClient`` (opened by the driver's ``SdkIntakeSession``)
holds the whole conversation. The container stays up until reaped.
The wiring helpers are unit-tested; ``main()`` (env + uvicorn + SDK) is not — it
needs the live container.
"""
from __future__ import annotations
import asyncio
import os
from contextlib import asynccontextmanager
from pathlib import Path
from typing import TYPE_CHECKING
import httpx
import structlog
from fastapi import FastAPI
from pydantic import BaseModel, Field
from roboco.agent_sdk.intake_driver import (
IntakeDriver,
StreamChunk,
build_intake_options,
)
if TYPE_CHECKING:
from collections.abc import AsyncIterator, Awaitable, Callable
logger = structlog.get_logger()
_RECEIVER_PORT = 9000 # ROBOCO_SDK_PORT — the orchestrator delivers messages here
class _Turn(BaseModel):
text: str = Field(..., min_length=1)
def make_message_source(
queue: asyncio.Queue[str | None],
) -> Callable[[], Awaitable[str | None]]:
"""A ``MessageSource`` backed by the receiver queue. ``None`` ends the loop."""
async def _next() -> str | None:
return await queue.get()
return _next
def make_relay_sink(
base_url: str, session_id: str, client: httpx.AsyncClient
) -> Callable[[StreamChunk], Awaitable[None]]:
"""An ``EventSink`` that POSTs each chunk to the orchestrator relay."""
url = f"{base_url}/api/prompter/live/{session_id}/events"
async def _emit(chunk: StreamChunk) -> None:
try:
await client.post(
url,
json={
"kind": chunk.kind,
"text": chunk.text,
"tool": chunk.tool,
"data": chunk.data,
},
)
except Exception as exc:
logger.error("Relay POST failed", session_id=session_id, error=str(exc))
return _emit
def build_receiver(queue: asyncio.Queue[str | None]) -> FastAPI:
"""The in-container HTTP receiver: `POST /turn` enqueues the human's message."""
app = FastAPI()
@app.post("/turn")
async def turn(body: _Turn) -> dict[str, bool]:
await queue.put(body.text)
return {"queued": True}
@app.get("/health")
async def health() -> dict[str, str]:
return {"status": "ok"}
return app
async def main() -> None: # pragma: no cover - needs the live container + SDK
"""Wire receiver + driver and run them concurrently for the chat's lifetime."""
import uvicorn
# Silence the Claude CLI's "configuration file not found at ~/.claude.json"
# warning (printed 3x at startup) that otherwise drowns the container logs.
# The CLI self-heals the file anyway; pre-creating an empty config just stops
# the noise before the SDK spawns the CLI. Best-effort — never fail the boot.
claude_json = Path.home() / ".claude.json"
if not claude_json.exists():
try:
claude_json.write_text("{}", encoding="utf-8")
except OSError as exc:
logger.warning("Could not pre-create ~/.claude.json", error=str(exc))
session_id = os.environ["ROBOCO_PROMPTER_SESSION_ID"]
base_url = os.environ.get("ROBOCO_API_URL", "http://roboco-orchestrator:8000")
cwd = os.environ.get("ROBOCO_WORKSPACE", "/data/workspace")
system_prompt = Path("/app/system-prompt.md").read_text(encoding="utf-8")
model = os.environ.get("CLAUDE_CODE_SUBAGENT_MODEL") or None
queue: asyncio.Queue[str | None] = asyncio.Queue()
client = httpx.AsyncClient(timeout=30.0)
# Tools + MCP + lockdown are all decided inside build_intake_options now
# (hard allowlist + the propose_draft tool + host-env isolation).
options = build_intake_options(
system_prompt=system_prompt,
cwd=cwd,
model=model,
)
from roboco.agent_sdk.intake_driver import SdkIntakeSession
@asynccontextmanager
async def session_factory() -> AsyncIterator[SdkIntakeSession]:
async with SdkIntakeSession(options) as session:
yield session
driver = IntakeDriver(
session_factory,
make_message_source(queue),
make_relay_sink(base_url, session_id, client),
)
# Bind all interfaces so the orchestrator reaches the receiver on the docker
# network. Built from octets (bandit B104 false positive — same as the SDK
# sidecar); override via ROBOCO_SDK_BIND_HOST for local dev.
bind_host = os.environ.get("ROBOCO_SDK_BIND_HOST", ".".join(["0"] * 4))
server = uvicorn.Server(
uvicorn.Config(
build_receiver(queue),
host=bind_host,
port=_RECEIVER_PORT,
log_level="warning",
)
)
logger.info("Intake container starting", session_id=session_id)
try:
await asyncio.gather(server.serve(), driver.run())
finally:
await client.aclose()
if __name__ == "__main__": # pragma: no cover
asyncio.run(main())
+7
View File
@@ -31,6 +31,7 @@ from roboco.api.routes.orchestrator import router as orchestrator_router
from roboco.api.routes.product import router as product_router
from roboco.api.routes.project import router as project_router
from roboco.api.routes.prompter import router as prompter_router
from roboco.api.routes.prompter_live import router as prompter_live_router
from roboco.api.routes.provider import router as provider_router
from roboco.api.routes.sessions import router as sessions_router
from roboco.api.routes.stream import router as stream_router
@@ -318,6 +319,12 @@ def create_app() -> FastAPI:
prefix=f"{api_prefix}/prompter",
tags=["Prompter"],
)
# Prompter live chat — panel <-> spawned intake agent (SSE + relay)
app.include_router(
prompter_live_router,
prefix=f"{api_prefix}/prompter",
tags=["Prompter"],
)
# Work Sessions
app.include_router(
+36 -25
View File
@@ -26,8 +26,8 @@ from roboco.api.schemas.prompter import (
PrompterDraftTask,
PrompterMessageRequest,
PrompterMessageResponse,
PrompterSessionCreateRequest,
PrompterSessionResponse,
PrompterTurnResponse,
TaskConfirmRequest,
TaskDraftResponse,
)
@@ -70,23 +70,24 @@ def _translate_error(e: ServiceError) -> HTTPException:
status_code=status.HTTP_201_CREATED,
)
async def create_session(
data: PrompterSessionCreateRequest,
db: DbSession,
agent: CurrentAgentContext,
) -> PrompterSessionResponse:
"""Create a new Prompter conversation session linked to the authenticated agent."""
service = get_prompter_service(db)
try:
session = await service.create_session(
agent_id=agent.agent_id,
context=data.context,
)
session = await service.create_session(agent_id=agent.agent_id)
except ServiceError as e:
raise _translate_error(e) from e
# Commit explicitly: the rest of the write surface (tasks, a2a, ...) does
# the same rather than rely on the request-teardown auto-commit, which is
# sensitive to middleware/teardown ordering. Without this the 201 is
# returned but the row may never persist, so the next request 404s.
await db.commit()
return PrompterSessionResponse(
id=session.id, # type: ignore[arg-type]
agent_id=session.agent_id, # type: ignore[arg-type]
id=UUID(str(session.id)),
agent_id=UUID(str(session.agent_id)),
status=session.status,
created_at=session.created_at,
updated_at=session.updated_at,
@@ -95,21 +96,22 @@ async def create_session(
@router.post(
"/sessions/{session_id}/messages",
response_model=list[PrompterMessageResponse],
response_model=PrompterTurnResponse,
)
async def send_message(
session_id: UUID,
data: PrompterMessageRequest,
db: DbSession,
agent: CurrentAgentContext,
) -> list[PrompterMessageResponse]:
) -> PrompterTurnResponse:
"""
Accept a user message, append it and an AI assistant response to the
conversation, and return the updated message list.
conversation, and return the updated message list plus the readiness
signal (``draft_ready`` and the coarse ``scale`` hint) for this turn.
"""
service = get_prompter_service(db)
try:
messages = await service.send_message(
turn = await service.send_message(
session_id=session_id,
agent_id=agent.agent_id,
content=data.content,
@@ -117,17 +119,22 @@ async def send_message(
)
except ServiceError as e:
raise _translate_error(e) from e
await db.commit()
return [
PrompterMessageResponse(
id=msg.id, # type: ignore[arg-type]
session_id=msg.session_id, # type: ignore[arg-type]
role=msg.role,
content=msg.content,
created_at=msg.created_at,
)
for msg in messages
]
return PrompterTurnResponse(
messages=[
PrompterMessageResponse(
id=UUID(str(msg.id)),
session_id=UUID(str(msg.session_id)),
role=msg.role,
content=msg.content,
created_at=msg.created_at,
)
for msg in turn.messages
],
draft_ready=turn.draft_ready,
scale=turn.scale,
)
@router.get(
@@ -153,6 +160,8 @@ async def get_draft(
)
except ServiceError as e:
raise _translate_error(e) from e
# Persist a newly generated draft (no-op when it was already cached).
await db.commit()
# Parse the stored draft_data into PrompterDraftTask for validation
try:
@@ -168,11 +177,11 @@ async def get_draft(
) from exc
return TaskDraftResponse(
id=draft_record.id, # type: ignore[arg-type]
session_id=draft_record.session_id, # type: ignore[arg-type]
id=UUID(str(draft_record.id)),
session_id=UUID(str(draft_record.session_id)),
draft=draft_task,
confirmed_at=draft_record.confirmed_at,
task_id=draft_record.task_id, # type: ignore[arg-type]
task_id=UUID(str(draft_record.task_id)) if draft_record.task_id else None,
created_at=draft_record.created_at,
)
@@ -203,10 +212,12 @@ async def confirm_draft(
product_id=data.product_id,
assigned_to=data.assigned_to,
extra=data.overrides,
draft=data.draft.model_dump(mode="json") if data.draft else None,
),
)
except ServiceError as e:
raise _translate_error(e) from e
await db.commit()
return {"task_id": str(task_id)}
+230
View File
@@ -0,0 +1,230 @@
"""Live intake chat — the panel <-> spawned-agent bridge.
Endpoints over the in-process ``PrompterLiveRegistry``:
- ``POST /live/start`` — spawn the intake container for a scope.
- ``GET /live/{session_id}/stream`` — SSE: the agent's live events to the panel.
- ``POST /live/{session_id}/messages`` — the human's message in (panel -> agent).
- ``POST /live/{session_id}/stop`` — reap the session (panel close / confirm).
- ``POST /live/{session_id}/events`` — the agent's events in (container -> relay).
Streaming/messaging require the session to be live (its ``prompter`` container
spawned, which calls ``registry.open``). Auth is intentionally light here —
sessions are keyed by an opaque id on a trusted network; token enforcement is
Phase 5.
"""
from __future__ import annotations
import json
from typing import TYPE_CHECKING, Any, Literal
from uuid import UUID, uuid4
from fastapi import APIRouter, HTTPException, Request, status
from pydantic import BaseModel, Field, model_validator
from sse_starlette import EventSourceResponse
from roboco.api.deps import CurrentAgentContext, DbSession, get_orchestrator
from roboco.services.base import NotFoundError, ServiceError, ValidationError
from roboco.services.prompter import get_prompter_service
from roboco.services.prompter_live import get_live_registry
if TYPE_CHECKING:
from collections.abc import AsyncGenerator
router = APIRouter()
def _translate_service_error(e: ServiceError) -> HTTPException:
"""Service error → HTTP status (mirrors the legacy prompter route)."""
if isinstance(e, NotFoundError):
return HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"error": "not_found", "message": e.message},
)
if isinstance(e, ValidationError):
return HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"error": "validation_error",
"message": e.message,
"field": e.field,
},
)
return HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail={"error": "internal_error", "message": e.message},
)
class StartLiveRequest(BaseModel):
"""Open a live intake chat scoped to a project XOR a product."""
project_id: UUID | None = None
product_id: UUID | None = None
initial_message: str | None = Field(default=None, min_length=1)
@model_validator(mode="after")
def _exactly_one_scope(self) -> StartLiveRequest:
if bool(self.project_id) == bool(self.product_id):
raise ValueError("provide exactly one of project_id / product_id")
return self
class StartLiveResponse(BaseModel):
"""The new session's id — the panel opens its stream and posts messages to it."""
session_id: str
class LiveMessageRequest(BaseModel):
"""The human's message in an active intake chat."""
text: str = Field(..., min_length=1)
class AgentEvent(BaseModel):
"""One normalized event the container relays (mirrors driver.StreamChunk)."""
kind: str
text: str = ""
tool: str = ""
data: dict[str, Any] = Field(default_factory=dict)
@router.post(
"/live/start",
response_model=StartLiveResponse,
status_code=status.HTTP_201_CREATED,
)
async def start_live(body: StartLiveRequest, db: DbSession) -> StartLiveResponse:
"""Spawn the intake agent for a new chat and return its session id.
The panel then opens ``/live/{session_id}/stream`` and posts messages to
``/live/{session_id}/messages``. An ``initial_message`` is delivered to the
agent automatically once its container is reachable.
"""
project_slug: str | None = None
if body.project_id is not None:
from roboco.services.project import get_project_service
project = await get_project_service(db).get(body.project_id)
if project is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Project {body.project_id} not found",
)
project_slug = project.slug
session_id = uuid4().hex
try:
# Non-blocking: opens the relay + spawns the container in the background,
# so this request returns immediately (no 60s timeout on clone/build/run).
await get_orchestrator().start_intake_session(
session_id,
project_slug=project_slug,
product_id=str(body.product_id) if body.product_id else None,
initial_message=body.initial_message,
)
except HTTPException:
raise
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to start intake session: {exc}",
) from exc
return StartLiveResponse(session_id=session_id)
@router.get("/live/{session_id}/stream")
async def stream(session_id: str, request: Request) -> EventSourceResponse:
"""Stream the agent's live events (token deltas, tool calls) to the panel."""
registry = get_live_registry()
async def events() -> AsyncGenerator[dict[str, Any]]:
async for event in registry.stream(session_id):
if await request.is_disconnected():
break
yield {"event": event.get("kind", "message"), "data": json.dumps(event)}
return EventSourceResponse(events(), ping=15)
@router.post("/live/{session_id}/messages")
async def send_message(session_id: str, body: LiveMessageRequest) -> dict[str, bool]:
"""Deliver the human's message to the running intake agent."""
delivered = await get_live_registry().deliver(session_id, body.text)
if not delivered:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={
"error": "not_found",
"message": f"No live intake session {session_id} (spawn it first).",
},
)
return {"delivered": True}
@router.post("/live/{session_id}/stop")
async def stop_live(session_id: str) -> dict[str, bool]:
"""Reap the live intake session (panel close, or draft confirmed)."""
await get_orchestrator().reap_intake_session(session_id)
return {"stopped": True}
class LiveConfirmRequest(BaseModel):
"""Confirm the agent's draft → a task, scoped to exactly one target.
``route`` is which start button the human pressed: ``"board"`` (Board review
& Start → PO + HoM review first) or ``"main_pm"`` (Approve & Start → straight
to the Main PM).
"""
project_id: UUID | None = None
product_id: UUID | None = None
draft: dict[str, Any]
route: Literal["board", "main_pm"] = "board"
@model_validator(mode="after")
def _exactly_one_target(self) -> LiveConfirmRequest:
if bool(self.project_id) == bool(self.product_id):
raise ValueError("provide exactly one of project_id / product_id")
return self
@router.post("/live/{session_id}/confirm", status_code=status.HTTP_201_CREATED)
async def confirm_live(
session_id: str,
body: LiveConfirmRequest,
db: DbSession,
agent: CurrentAgentContext,
) -> dict[str, str]:
"""Turn the agent's confirmed draft into a started (pending) task, then reap.
Per ``route``: "Board review & Start" assigns it to the Board (PO + HoM
review first); "Approve & Start" assigns it straight to the Main PM.
Attributed to the confirming agent (the CEO). On success the live session is
reaped — the chat is over once the draft is a task.
"""
service = get_prompter_service(db)
try:
task_id = await service.confirm_live_draft(
body.draft,
agent.agent_id,
project_id=body.project_id,
product_id=body.product_id,
route=body.route,
)
except ServiceError as e:
raise _translate_service_error(e) from e
await db.commit()
# The draft is now a task — reap the agent + close the relay stream.
await get_orchestrator().reap_intake_session(session_id)
return {"task_id": str(task_id)}
@router.post("/live/{session_id}/events")
async def relay_event(session_id: str, event: AgentEvent) -> dict[str, bool]:
"""Relay one agent event from the container onto the session's stream."""
return {"pushed": get_live_registry().push(session_id, event.model_dump())}
+68 -9
View File
@@ -45,15 +45,6 @@ class ChatMessage(BaseModel):
# =============================================================================
class PrompterSessionCreateRequest(BaseModel):
"""Request body for POST /api/prompter/sessions."""
context: dict[str, Any] = Field(
default_factory=dict,
description="Optional bootstrap context (project_id, team, etc.)",
)
class PrompterSessionResponse(BaseModel):
"""Response for session creation and retrieval."""
@@ -88,6 +79,21 @@ class PrompterMessageResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
class PrompterTurnResponse(BaseModel):
"""Result of a chat turn: the full message list plus the readiness signal.
Carries ``draft_ready`` (and the coarse ``scale`` hint) so the frontend
consumes the backend's judgement instead of re-deriving it by string match.
"""
messages: list[PrompterMessageResponse]
draft_ready: bool = False
scale: str | None = Field(
default=None,
description="Coarse size hint from the assistant: 'single' or 'multi'",
)
class TaskConfirmRequest(BaseModel):
"""Request body for POST /api/prompter/sessions/{id}/confirm.
@@ -111,6 +117,14 @@ class TaskConfirmRequest(BaseModel):
default_factory=dict,
description="Additional fields to override in the draft before task creation",
)
draft: "PrompterDraftTask | None" = Field(
default=None,
description=(
"The human-edited structured draft. When present it replaces the "
"stored draft (after re-validation and description re-composition) "
"before the task is created."
),
)
# =============================================================================
@@ -118,11 +132,35 @@ class TaskConfirmRequest(BaseModel):
# =============================================================================
class CellWork(BaseModel):
"""One cell's slice of a task's work — the per-cell breakdown of The Work.
For a single-cell task there is exactly one entry; a board-led feature
carries one entry per participating cell.
"""
team: Team = Field(
..., description="The cell (or coordinating team) doing this work"
)
summary: str = Field(
..., min_length=1, description="One-line summary of this cell's slice"
)
items: list[str] = Field(
default_factory=list, description="Concrete deliverables for this cell"
)
class PrompterDraftTask(BaseModel):
"""A task draft produced by the Prompter.
Mirrors TaskCreate fields so the frontend can POST /api/tasks
with confirmed_by_human=True after human review.
The structured spec fields (``objective``, ``what_this_builds``,
``the_work``, ``notes``) are first-class in this contract but persisted
inside the existing ``draft_data`` JSONB column — no migration. The backend
composes ``description`` deterministically from them; ``acceptance_criteria``
renders as Success Criteria.
"""
title: str = Field(..., min_length=1, max_length=200)
@@ -133,6 +171,23 @@ class PrompterDraftTask(BaseModel):
task_type: TaskType = Field(...)
nature: TaskNature = Field(...)
estimated_complexity: Complexity = Field(...)
# Structured spec fields — optional for backward compatibility.
objective: str | None = Field(
default=None,
description="The outcome this task delivers, in one or two sentences",
)
what_this_builds: list[str] = Field(
default_factory=list, description="Concrete artifacts this task produces"
)
the_work: list[CellWork] = Field(
default_factory=list,
description="Per-cell breakdown; length drives single vs multi-cell",
)
notes: list[str] = Field(
default_factory=list,
description="Constraints, reuse pointers, things to confirm with the human",
)
project_id: str | None = Field(
default=None,
description=(
@@ -161,6 +216,10 @@ class PrompterDraftTask(BaseModel):
confirmed_by_human: bool = False
# Resolve TaskConfirmRequest.draft now that PrompterDraftTask exists.
TaskConfirmRequest.model_rebuild()
class TaskDraftResponse(BaseModel):
"""Response for GET /api/prompter/sessions/{id}/draft."""
+12
View File
@@ -28,6 +28,7 @@ class Role(StrEnum):
PRODUCT_OWNER = "product_owner"
HEAD_MARKETING = "head_marketing"
AUDITOR = "auditor"
PROMPTER = "prompter" # intake interviewer — talks only to the human, drafts tasks
CEO = "ceo"
SYSTEM = "system" # sentinel only — used for orchestrator-generated rows
@@ -53,6 +54,7 @@ CELL_TEAMS: frozenset[Team] = frozenset({Team.BACKEND, Team.FRONTEND, Team.UX_UI
class RoleLevel(IntEnum):
SYSTEM = -1
INTAKE = 0 # read-only intake interviewer — lowest real-agent authority
DEV = 1
QA = 2
DOCUMENTER = 3
@@ -189,6 +191,15 @@ AGENTS: dict[str, AgentRow] = {
"auditor": AgentRow(
"auditor", Role.AUDITOR, Team.BOARD, _u("00000000-0000-0000-0004-000000000004")
),
# Intake interviewer — CEO-adjacent (board team), but NOT a board reviewer
# (deliberately absent from BOARD_ROLES). Spawned on demand to chat with the
# human and draft a task; talks to no other agent.
"intake-1": AgentRow(
"intake-1",
Role.PROMPTER,
Team.BOARD,
_u("00000000-0000-0000-0004-000000000005"),
),
}
@@ -213,6 +224,7 @@ ROLE_LEVEL: dict[Role, RoleLevel] = {
Role.PRODUCT_OWNER: RoleLevel.BOARD,
Role.HEAD_MARKETING: RoleLevel.BOARD,
Role.AUDITOR: RoleLevel.AUDITOR,
Role.PROMPTER: RoleLevel.INTAKE,
Role.CEO: RoleLevel.CEO,
}
+3
View File
@@ -58,6 +58,9 @@ ROLE_READ_TIERS: dict[Role, ReadTier] = {
Role.PRODUCT_OWNER: ReadTier.ALL_CELLS,
Role.HEAD_MARKETING: ReadTier.ALL_CELLS,
Role.AUDITOR: ReadTier.ALL,
# Intake interviewer is isolated — talks only to the human, reads only its
# own journal.
Role.PROMPTER: ReadTier.OWN,
Role.CEO: ReadTier.ALL,
}
+6 -1
View File
@@ -906,7 +906,12 @@ _INTENT_VERBS: dict[str, IntentSpec] = {
| _QA_ROLES
| _DOC_ROLES
| _PM_ROLES
| {Role.PRODUCT_OWNER, Role.HEAD_MARKETING, Role.AUDITOR}
| {
Role.PRODUCT_OWNER,
Role.HEAD_MARKETING,
Role.AUDITOR,
Role.PROMPTER,
}
),
description=(
"Signal you have no active work. PMs auto-pause owned in_progress tasks."
+2
View File
@@ -107,4 +107,6 @@ ROLE_MODEL_MAP: dict[str, str] = {
"product_owner": "opus",
"head_marketing": "opus",
"ceo": "opus",
# Intake interviewer — reads real code and drafts the spec; needs to be sharp.
"prompter": "opus",
}
+454 -12
View File
@@ -26,7 +26,7 @@ from typing import TYPE_CHECKING, Any, ClassVar
import httpx
if TYPE_CHECKING:
from collections.abc import Callable
from collections.abc import Callable, Coroutine
from roboco.services.llm import AgentRoute
from roboco.services.task import TaskService
@@ -69,6 +69,12 @@ AgentConfig = OrchestratorAgentConfig
AGENT_NETWORK = "roboco_default"
AGENT_BASE_IMAGE = "roboco-agent-base"
# The intake (prompter) agent: a single seeded, board-adjacent interviewer.
# Unlike delivery agents it is never dispatched and runs ONE persistent
# container at a time (single CEO → one live chat). See the INTAKE section
# below and roboco/agent_sdk/intake_main.py.
INTAKE_AGENT_ID = "intake-1"
# Role -> Image mapping
# Specialized images extend the base with role-specific tools
AGENT_IMAGES: dict[str, str] = {
@@ -95,6 +101,8 @@ AGENT_IMAGES: dict[str, str] = {
"product-owner": "roboco-agent-pm",
"head-marketing": "roboco-agent-pm",
"auditor": "roboco-agent-pm",
# Intake — persistent Agent-SDK driver, not a one-shot `claude -p`.
INTAKE_AGENT_ID: "roboco-agent-prompter",
}
@@ -129,6 +137,21 @@ class _SlaBreach:
sla_seconds: int
@dataclass(frozen=True)
class _IntakeRunSpec:
"""Inputs for ``_build_intake_run_cmd``, bundled to keep the signature small."""
container_name: str
image: str
hosts: dict[str, str | None]
session_id: str
cwd: str
cli_model: str
api_url: str
provider_base_url: str | None
provider_auth_token: str | None
def _read_project_slug(task: dict[str, Any]) -> str | None:
"""Extract project slug from a task payload shape-tolerantly."""
slug = task.get("project_slug")
@@ -636,6 +659,7 @@ class AgentOrchestrator:
"roboco-agent-qa-fe": "agent-qa-fe.Dockerfile",
"roboco-agent-doc": "agent-doc.Dockerfile",
"roboco-agent-ux": "agent-ux.Dockerfile",
"roboco-agent-prompter": "agent-prompter.Dockerfile",
}
dockerfile = dockerfile_map.get(image)
if dockerfile:
@@ -2451,6 +2475,395 @@ class AgentOrchestrator:
git_context=self._task_git_context(task),
)
# =========================================================================
# INTAKE (PROMPTER) LIVE SESSION
#
# The intake agent is not task-driven and is never dispatched. It is a
# persistent Claude-Agent-SDK driver the CEO chats with live (the container
# entrypoint is roboco.agent_sdk.intake_main). One fixed container —
# `intake-1`, the seeded board-adjacent interviewer — serves one live
# session at a time (single CEO; one-session-per-CEO).
#
# This spawn is a DELIBERATELY separate path from spawn_agent: no task, no
# readiness gate, no `claude -p` CLI args (the image ENTRYPOINT is the
# driver), no settings.json/hook mount (the driver owns the receiver on
# port 9000, not the inbox sidecar), and no MCP/gateway surface (the live
# agent reads code with Read/Grep/Glob and talks only to the human).
# =========================================================================
async def start_intake_session(
self,
session_id: str,
*,
project_slug: str | None = None,
product_id: str | None = None,
initial_message: str | None = None,
) -> None:
"""Non-blocking start: open the relay now, spawn the container in the bg.
The panel's ``POST /live/start`` returns immediately rather than blocking
on the workspace clone + first-time image build + ``docker run`` (which
can exceed the HTTP timeout the cause of the "Request timed out" the
panel showed). The panel opens the SSE stream right away; the agent's
first reply arrives once the container is up. A spawn failure is pushed
onto the relay as an ``error`` event and closes the session, so the panel
shows it instead of hanging. Exactly one of ``project_slug`` /
``product_id`` must be given.
"""
if bool(project_slug) == bool(product_id):
raise ValueError(
"intake scope requires exactly one of project_slug / product_id"
)
self._open_intake_relay(session_id)
self._schedule_bg(
self._spawn_intake_container_guarded(
session_id,
project_slug=project_slug,
product_id=product_id,
initial_message=initial_message,
)
)
async def spawn_intake_session(
self,
session_id: str,
*,
project_slug: str | None = None,
product_id: str | None = None,
initial_message: str | None = None,
) -> AgentInstance:
"""Spawn the intake container for one live chat, **synchronously**.
Opens the relay then clones + launches the container, awaiting the whole
thing. Prefer ``start_intake_session`` on the request path; this blocking
variant is for direct/internal callers and tests. Exactly one of
``project_slug`` / ``product_id`` must be given.
"""
if bool(project_slug) == bool(product_id):
raise ValueError(
"intake scope requires exactly one of project_slug / product_id"
)
self._open_intake_relay(session_id)
return await self._spawn_intake_container(
session_id,
project_slug=project_slug,
product_id=product_id,
initial_message=initial_message,
)
@staticmethod
def _open_intake_relay(session_id: str) -> None:
"""Register the live relay session so the SSE stream connects immediately."""
from roboco.services.prompter_live import get_live_registry
get_live_registry().open(session_id, INTAKE_AGENT_ID)
async def _spawn_intake_container_guarded(
self,
session_id: str,
*,
project_slug: str | None,
product_id: str | None,
initial_message: str | None,
) -> None:
"""Background container spawn; surface failures on the relay, not silently."""
from roboco.services.prompter_live import get_live_registry
try:
await self._spawn_intake_container(
session_id,
project_slug=project_slug,
product_id=product_id,
initial_message=initial_message,
)
except Exception as exc:
logger.error(
"Intake container spawn failed", session_id=session_id, error=str(exc)
)
registry = get_live_registry()
registry.push(
session_id,
{"kind": "error", "text": f"Couldn't start the intake agent: {exc}"},
)
registry.close(session_id)
async def _spawn_intake_container(
self,
session_id: str,
*,
project_slug: str | None,
product_id: str | None,
initial_message: str | None,
) -> AgentInstance:
"""Clone the scope, launch the SDK-driver container, track the instance.
The relay must already be open (``_open_intake_relay``). Heavy + slow
(clone + first-time image build + docker run) keep it off the request
path via ``start_intake_session``.
"""
# Single live session: reap any prior intake container before spawning.
if INTAKE_AGENT_ID in self._instances:
await self.stop_agent(INTAKE_AGENT_ID, graceful=False)
cwd, cloned = await self._clone_intake_scope(project_slug, product_id)
prompt_path = self._generate_composed_prompt(INTAKE_AGENT_ID)
route = await self._resolve_agent_route(INTAKE_AGENT_ID)
cli_model = _resolve_agent_cli_model(
route.provider_type.value, route.model_name
)
api_url = (
"http://roboco-orchestrator:8000"
if PROJECT_HOST_PATH
else f"http://127.0.0.1:{settings.port}"
)
await self._ensure_agent_image(INTAKE_AGENT_ID)
container_name = f"roboco-agent-{INTAKE_AGENT_ID}"
await self._remove_container(container_name)
cmd = self._build_intake_run_cmd(
_IntakeRunSpec(
container_name=container_name,
image=get_agent_image(INTAKE_AGENT_ID),
hosts=self._resolve_intake_host_paths(),
session_id=session_id,
cwd=cwd,
cli_model=cli_model,
api_url=api_url,
provider_base_url=route.base_url,
provider_auth_token=route.auth_token,
)
)
container_id = await self._run_container_cmd(cmd)
config = AgentConfig(
agent_id=INTAKE_AGENT_ID,
blueprint_path=prompt_path,
model=route.model_name,
git_context=None,
)
instance = AgentInstance(
agent_id=INTAKE_AGENT_ID,
state=AgentState.ACTIVE,
config=config,
current_task_id=None,
)
instance.container_id = container_id
instance.started_at = datetime.now(UTC)
instance.last_activity = datetime.now(UTC)
self._instances[INTAKE_AGENT_ID] = instance
# The relay was already opened on the request path (start_intake_session /
# spawn_intake_session) BEFORE the panel connected its SSE stream. Do NOT
# re-open here: a second open would swap in a fresh queue and orphan that
# already-connected stream (the agent's replies would push to the new queue
# while the browser keeps reading the old one). open() is idempotent now as
# a guard, but the redundant call is gone regardless.
logger.info(
"Intake session spawned",
session_id=session_id,
container_id=container_id[:12],
cwd=cwd,
repos=len(cloned),
)
self._fire_audit(
event_type="agent.spawned",
agent_slug=INTAKE_AGENT_ID,
details={"session_id": session_id, "cwd": cwd, "repos": cloned},
)
if initial_message:
self._schedule_intake_first_message(session_id, initial_message)
return instance
async def reap_intake_session(self, session_id: str) -> None:
"""End a live chat: close the relay stream and stop the container."""
from roboco.services.prompter_live import get_live_registry
get_live_registry().close(session_id)
await self.stop_agent(INTAKE_AGENT_ID, graceful=True)
logger.info("Intake session reaped", session_id=session_id)
async def _clone_intake_scope(
self, project_slug: str | None, product_id: str | None
) -> tuple[str, list[str]]:
"""Clone the chat scope's repo(s); return (container cwd, all paths).
``project`` one repo; ``product`` each distinct cell project (the
Main-PM-style distinct-repo set, kept in its deterministic team order so
the primary is stable). The agent's cwd is the primary project's intake
workspace; for a product the sibling repos sit alongside it under
``/data/workspaces`` and are readable via Grep/Glob/Read.
"""
from roboco.db.base import get_session_factory
from roboco.services.workspace import WorkspaceService
team = get_agent_team(INTAKE_AGENT_ID) or "board"
factory = get_session_factory()
async with factory() as db:
slugs = await self._intake_scope_slugs(db, project_slug, product_id)
ws = WorkspaceService(db)
for slug in slugs:
await ws.ensure_workspace(slug, INTAKE_AGENT_ID)
# Container-side paths (the workspaces tree is mounted at
# /data/workspaces inside the container, regardless of the host root).
paths = [_agent_workspace_path(slug, team, INTAKE_AGENT_ID) for slug in slugs]
return paths[0], paths
@staticmethod
async def _intake_scope_slugs(
db: Any, project_slug: str | None, product_id: str | None
) -> list[str]:
"""Resolve the chat scope to the project slug(s) to clone."""
if project_slug:
return [project_slug]
if not product_id:
raise ValueError("intake scope requires project_slug or product_id")
from uuid import UUID
from roboco.services.product import ProductService
from roboco.services.project import get_project_service
project_ids = await ProductService(db).distinct_project_ids(UUID(product_id))
project_svc = get_project_service(db)
slugs: list[str] = []
for pid in project_ids:
project = await project_svc.get(pid)
if project and project.slug:
slugs.append(project.slug)
if not slugs:
raise ValueError(f"product {product_id} resolves to no projects")
return slugs
def _resolve_intake_host_paths(self) -> dict[str, str | None]:
"""Host paths for the intake container's three mounts (claude/prompt/ws).
Mirrors ``_resolve_host_paths`` but only for what the driver needs
there is no settings.json, MCP config, or briefing for the intake agent.
"""
if PROJECT_HOST_PATH:
return {
"claude": CLAUDE_AUTH_HOST_PATH,
"prompt": (
f"{DATA_HOST_PATH}/prompts-generated/{INTAKE_AGENT_ID}-prompt.md"
),
"workspaces": f"{DATA_HOST_PATH}/workspaces",
}
return {
"claude": CLAUDE_AUTH_HOST_PATH,
"prompt": str(
Path(tempfile.gettempdir())
/ "roboco-prompts"
/ f"{INTAKE_AGENT_ID}-prompt.md"
),
"workspaces": str(Path(settings.workspaces_root)),
}
@staticmethod
def _build_intake_run_cmd(spec: _IntakeRunSpec) -> list[str]:
"""Compose the `docker run` argv for the persistent intake container.
No claude CLI args (the image ENTRYPOINT is the SDK driver), no
settings.json/hook mount (the driver owns port 9000), no MCP config.
The driver reads ``/app/system-prompt.md`` and the env below.
"""
cmd: list[str] = [
"docker",
"run",
"-d",
"--name",
spec.container_name,
"--network",
AGENT_NETWORK,
"-v",
f"{spec.hosts['claude']}:/home/agent/.claude",
]
AgentOrchestrator._append_claude_json_mount(cmd, spec.hosts)
cmd.extend(
[
"-v",
f"{spec.hosts['prompt']}:/app/system-prompt.md:ro",
"-v",
f"{spec.hosts['workspaces']}:/data/workspaces",
"-e",
f"ROBOCO_AGENT_ID={INTAKE_AGENT_ID}",
"-e",
f"ROBOCO_AGENT_ROLE={get_agent_role(INTAKE_AGENT_ID) or 'prompter'}",
"-e",
f"ROBOCO_API_URL={spec.api_url}",
"-e",
f"ROBOCO_PROMPTER_SESSION_ID={spec.session_id}",
"-e",
f"ROBOCO_WORKSPACE={spec.cwd}",
"-e",
f"CLAUDE_CODE_SUBAGENT_MODEL={spec.cli_model}",
]
)
# Non-Anthropic providers need explicit endpoint/token; the Anthropic
# default uses the mounted ~/.claude login (same as every agent).
if spec.provider_base_url:
cmd.extend(["-e", f"ANTHROPIC_BASE_URL={spec.provider_base_url}"])
if spec.provider_auth_token:
cmd.extend(["-e", f"ANTHROPIC_AUTH_TOKEN={spec.provider_auth_token}"])
cmd.append(spec.image)
return cmd
async def _run_container_cmd(self, cmd: list[str]) -> str:
"""Run a detached `docker run` and return the container id."""
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await proc.communicate()
if proc.returncode != 0:
raise RuntimeError(f"Failed to start intake container: {stderr.decode()}")
return stdout.decode().strip()
def _schedule_bg(self, coro: "Coroutine[Any, Any, None]") -> None:
"""Fire-and-forget a coroutine, strong-reffed so it isn't GC'd mid-flight.
Silently no-ops when there's no running loop (sync unit tests); the coro
is closed to avoid a "never awaited" warning.
"""
import contextlib as _ctx
try:
loop = asyncio.get_running_loop()
except RuntimeError:
with _ctx.suppress(Exception):
coro.close()
return
bg = loop.create_task(coro)
self._bg_tasks.add(bg)
bg.add_done_callback(self._bg_tasks.discard)
def _schedule_intake_first_message(self, session_id: str, text: str) -> None:
"""Fire-and-forget the opening message once the container is reachable."""
self._schedule_bg(self._deliver_when_ready(session_id, text))
async def _deliver_when_ready(
self,
session_id: str,
text: str,
*,
attempts: int = 30,
delay: float = 1.0,
) -> None:
"""Retry-deliver the first message until the container receiver is up."""
from roboco.services.prompter_live import get_live_registry
registry = get_live_registry()
for _ in range(attempts):
if await registry.deliver(session_id, text):
return
await asyncio.sleep(delay)
logger.warning(
"Intake first message never delivered (receiver never came up)",
session_id=session_id,
)
# =========================================================================
# AGENT STOPPING
# =========================================================================
@@ -2799,6 +3212,39 @@ Start by:
# same session.
await self._sweep_budget_exceeded()
@staticmethod
async def _fetch_budget_status(
client: httpx.AsyncClient, url: str, agent_id: str
) -> dict[str, Any] | None:
"""Read an agent's SDK budget status; None if unreachable/not-JSON.
The SDK being unreachable is benign (container not yet started, already
gone, or a transient blip) and the health loop covers genuine failures,
so the failure is swallowed but logged at debug so it is observable
rather than silent (the bare try/except/continue it replaced was not).
"""
try:
resp = await client.get(url)
except httpx.HTTPError as exc:
logger.debug(
"Budget status unreachable; skipping agent this sweep",
agent_id=agent_id,
error=str(exc),
)
return None
if resp.status_code != http_status.HTTP_200_OK:
return None
try:
data = resp.json()
except ValueError as exc:
logger.debug(
"Budget status not JSON; skipping agent this sweep",
agent_id=agent_id,
error=str(exc),
)
return None
return data if isinstance(data, dict) else None
async def _sweep_budget_exceeded(self) -> None:
"""Stop agents whose per-session SDK budget reports halt=true.
@@ -2818,16 +3264,8 @@ Start by:
):
continue
url = f"http://roboco-agent-{agent_id}:9000/budget/status"
try:
resp = await client.get(url)
if resp.status_code != http_status.HTTP_200_OK:
continue
data = resp.json()
except Exception:
# SDK unreachable / not yet started / container gone —
# either benign or covered by health loop.
continue
if not data.get("halt"):
data = await self._fetch_budget_status(client, url, agent_id)
if data is None or not data.get("halt"):
continue
logger.warning(
"Agent budget exceeded; terminating container",
@@ -2986,7 +3424,11 @@ Start by:
agent_id=agent_id,
)
return
from_agent = auditor.id if auditor else ceo.id # type: ignore[union-attr]
# recipients is non-empty (guarded above) and already holds the
# non-None ids in auditor-then-ceo order — its first entry is the
# same value as `auditor.id if auditor else ceo.id`, without the
# union-narrowing mypy can't prove.
from_agent = recipients[0]
notification = NotificationTable(
type=NotificationType.ALERT,
priority=NotificationPriority.HIGH,
+1
View File
@@ -107,6 +107,7 @@ _AGENT_PRESENTATION: dict[str, dict[str, Any]] = {
"product-owner": {"name": "Product Owner"},
"head-marketing": {"name": "Head of Marketing"},
"auditor": {"name": "Auditor"},
"intake-1": {"name": "Intake"},
}
+20
View File
@@ -113,6 +113,15 @@ _AUDITOR_FLOW = spec.intents_for_role(spec.Role.AUDITOR)
# no ack (silent observer — wouldn't ack notifications). channels for read map.
_AUDITOR_DO = ("note", "evidence", "notify_list", "notify_get", *_CHANNEL_DISCOVERY)
_PROMPTER_FLOW = spec.intents_for_role(
spec.Role.PROMPTER
) # none — not a lifecycle role
# Intake interviewer: human-only. It journals (note) and cites sources
# (evidence) but has NO outward agent comms — no say (channels), no dm/notify
# (agents), no channel discovery. Its conversation with the human runs over the
# live-session bridge, not these gateway tools.
_PROMPTER_DO = ("note", "evidence")
ROLE_CONFIGS: dict[str, RoleConfig] = {
"developer": RoleConfig(
@@ -179,6 +188,17 @@ ROLE_CONFIGS: dict[str, RoleConfig] = {
allows_subagent=False,
description="Silent observer; reads but never communicates outwardly.",
),
"prompter": RoleConfig(
role="prompter",
flow_tools=_PROMPTER_FLOW,
do_tools=_PROMPTER_DO,
allows_write=False,
allows_subagent=True,
description=(
"Intake interviewer; chats only with the human, reads the codebase, "
"and drafts a task. No outward agent comms; never writes or merges."
),
),
}
+536 -134
View File
@@ -14,9 +14,10 @@ from __future__ import annotations
import contextlib
import json
import re
from dataclasses import dataclass, field
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, Literal
from uuid import UUID, uuid4
import httpx
@@ -30,7 +31,8 @@ from roboco.db.tables import (
TaskDraftTable,
TaskTable,
)
from roboco.models.base import Complexity, TaskNature, TaskType, Team
from roboco.foundation.identity import CELL_TEAMS
from roboco.models.base import Complexity, TaskNature, TaskStatus, TaskType, Team
from roboco.models.task import TaskCreateRequest
from roboco.services.base import NotFoundError, ServiceError, ValidationError
@@ -53,6 +55,25 @@ class ConfirmOverrides:
product_id: UUID | None = None
assigned_to: str | None = None
extra: dict[str, Any] = field(default_factory=dict)
draft: dict[str, Any] | None = None
@dataclass
class ReadinessTag:
"""Parsed contents of an assistant turn's trailing roboco-meta block."""
covered: list[str] = field(default_factory=list)
ready: bool = False
scale: str | None = None
@dataclass
class TurnResult:
"""Outcome of a chat turn: the message list plus the readiness signal."""
messages: list[PrompterMessageTable]
draft_ready: bool = False
scale: str | None = None
# ---------------------------------------------------------------------------
@@ -60,58 +81,91 @@ class ConfirmOverrides:
# ---------------------------------------------------------------------------
_PROMPTER_SYSTEM_PROMPT = (
"You are the RoboCo Prompter — a conversational assistant that helps "
"users draft tasks for an AI agentic company.\n\n"
"Your job is to:\n"
"1. Ask clarifying questions to gather requirements.\n"
"2. Keep the conversation focused on producing a well-scoped task.\n"
"3. When you believe you have enough context, signal that a draft is "
"ready.\n"
"4. Never create the task yourself — only help the user articulate what "
"needs to be built.\n\n"
"Key rules:\n"
"- Be concise but thorough.\n"
"- Always ask for acceptance criteria if the user hasn't provided them.\n"
"- Suggest a team (backend, frontend, ux_ui) based on the work "
"described.\n"
"- Estimate complexity (low, medium, high) and task type (code, "
"documentation, research, planning, design, administrative).\n"
"- Determine nature (technical vs non_technical).\n"
"- If the user describes a bug, suggest a code task with technical "
"nature.\n"
"- If the user describes a feature, determine whether it's backend, "
"frontend, or UX/UI work.\n\n"
"When you have enough information to produce a complete draft, say so "
"explicitly with 'I have enough information to draft a task' or "
"'ready to draft'."
"You are the RoboCo Prompter — the intake interviewer for an AI agentic "
"software company. A human describes something they want built; you ask a "
"few sharp questions, then a launch-ready task spec is handed to the dev "
"teams.\n\n"
"How RoboCo is organized:\n"
"- A human CEO sits above a Board (Product Owner, Head of Marketing, "
"Auditor).\n"
"- The Main PM coordinates three delivery cells — Backend, Frontend, and "
"UX/UI. Each cell has developers, a QA, a PM, and a documenter.\n"
"- Small, single-domain work (a bug fix, one endpoint, one component) is "
"one task owned by one cell.\n"
"- A real feature is board-led: the Board sets requirements, the Main PM "
"delegates one subtask per participating cell, and the cells deliver in "
"parallel.\n\n"
"What a well-formed task looks like (the house standard):\n"
"- Objective — the outcome, not the implementation.\n"
"- What This Builds — the concrete artifacts.\n"
"- The Work — the per-cell breakdown (one cell for small work; Backend, "
"Frontend, UX/UI for a feature).\n"
"- Notes — constraints, what to reuse, anything to confirm with the human.\n"
"- Success Criteria — verifiable acceptance criteria.\n\n"
"Your interview discipline:\n"
"- Open by reflecting back, in one or two sentences, what you understand "
"they want, so they can correct course immediately.\n"
"- Then ask only the highest-leverage questions you are actually missing — "
"one or two per turn. Never dump a checklist.\n"
"- Before you can draft, cover: (1) the true objective, (2) scope "
"boundaries — what is explicitly out, (3) the surface — which page, "
"endpoint, or component, grounded in the projects/products you are shown, "
"(4) reuse vs build — what existing code or services to lean on, "
"(5) the audience, (6) what 'done' looks like.\n"
"- Stop as soon as objective, scope, surface, and acceptance are clear. "
"Aim for two to four turns total. Do not pad the conversation.\n"
"- Use the real project and product names you are given; prefer an "
"existing surface over inventing one.\n\n"
"Every reply ends with exactly one fenced control block the human never "
"sees, reporting coverage and readiness:\n"
"```roboco-meta\n"
'{"covered": ["objective", "scope", "surface", "acceptance"], '
'"ready": false, "scale": "single"}\n'
"```\n"
"- covered: which of objective / scope / surface / reuse / audience / "
"acceptance you have nailed down.\n"
"- ready: true only when you could write a complete task spec right now.\n"
"- scale: 'single' for one-cell work, 'multi' for a board-led feature "
"across cells.\n"
"Write nothing after that block."
)
_DRAFT_SYSTEM_PROMPT = (
"You are the RoboCo Prompter — an expert at converting conversations "
"into structured task drafts.\n\n"
"Given a conversation between a user and the Prompter assistant, "
"produce a JSON task draft that conforms to the RoboCo task schema.\n\n"
"You are the RoboCo Prompter's drafting engine. Given a finished "
"conversation, output a single JSON object — a structured task "
"draft. No markdown, no prose, no code fence.\n\n"
"Required fields:\n"
"- title: concise, actionable task title (max 200 chars)\n"
"- description: detailed description, min 20 chars, explaining what "
"needs to be done\n"
"- acceptance_criteria: list of strings, each a verifiable criterion "
"(min 1)\n"
"- team: one of backend, frontend, ux_ui\n"
"- title: concise, actionable (max 200 chars).\n"
"- objective: the outcome in one or two sentences.\n"
"- what_this_builds: array of concrete artifacts (strings).\n"
"- the_work: array of per-cell slices. Each item is "
'{"team": backend|frontend|ux_ui, "summary": one line, '
'"items": [deliverables]}. One entry for single-cell work; one entry per '
"participating cell for a board-led feature.\n"
"- acceptance_criteria: array of verifiable criteria (at least one) — "
"these become Success Criteria.\n"
"- notes: array of constraints, reuse pointers, things to confirm (may be "
"empty).\n"
"- team: the primary cell (backend|frontend|ux_ui). For a multi-cell "
"feature set the lead cell here; the backend routes it through the Main "
"PM.\n"
"- task_type: one of code, documentation, research, planning, design, "
"administrative\n"
"- nature: one of technical, non_technical\n"
"- estimated_complexity: one of low, medium, high\n"
"- priority: integer 0-3 (0=P0 highest, 3=P3 lowest)\n\n"
"Optional fields:\n"
"- project_id: UUID string if known from context\n"
"- product_id: UUID string if known from context (only one of "
"project_id/product_id should be set)\n"
"- assigned_to: agent slug or UUID if the user specified one\n"
"- target_date: ISO-8601 date string if mentioned\n\n"
'Always set source="prompter" and confirmed_by_human=false.\n\n'
"Return ONLY valid JSON matching the PrompterDraftTask schema. No "
"markdown, no preamble."
"administrative.\n"
"- nature: technical or non_technical.\n"
"- estimated_complexity: low, medium, high.\n"
"- priority: integer 0-3 (0 highest, 3 lowest).\n\n"
"Optional, only if unambiguous from context: project_id, product_id, "
"assigned_to, target_date.\n\n"
"Do NOT write a 'description' field — the backend composes it from your "
"structured fields.\n\n"
"Example shape (abbreviated):\n"
'{"title": "...", "objective": "...", "what_this_builds": ["..."], '
'"the_work": [{"team": "backend", "summary": "...", "items": ["..."]}, '
'{"team": "frontend", "summary": "...", "items": ["..."]}], '
'"acceptance_criteria": ["..."], "notes": ["..."], "team": "backend", '
'"task_type": "code", "nature": "technical", '
'"estimated_complexity": "high", "priority": 1}\n\n'
"Return ONLY the JSON object."
)
@@ -165,11 +219,7 @@ class PrompterService:
# Session-based interface
# -----------------------------------------------------------------------
async def create_session(
self,
agent_id: UUID,
context: dict[str, Any] | None = None, # noqa: ARG002
) -> PrompterSessionTable:
async def create_session(self, agent_id: UUID) -> PrompterSessionTable:
"""Create a new Prompter conversation session."""
session = PrompterSessionTable(
id=uuid4(),
@@ -188,10 +238,10 @@ class PrompterService:
agent_id: UUID,
content: str,
context: dict[str, Any] | None = None,
) -> list[PrompterMessageTable]:
) -> TurnResult:
"""
Append a user message, call the LLM for a reply, persist both,
and return all messages in the session.
Append a user message, call the LLM for a reply, persist both, and
return all messages plus the readiness signal for this turn.
"""
session = await self._get_session(session_id, agent_id)
@@ -210,13 +260,17 @@ class PrompterService:
history = await self._load_messages(session_id)
chat_messages = [{"role": m.role, "content": m.content} for m in history]
# Ground the interview in the real projects/products the human can target
live_context = await self._assemble_live_context()
# Call the LLM
llm_reply = await self._llm_chat(
messages=chat_messages,
context=context,
live_context=live_context,
)
# Persist the assistant reply
# Persist the assistant reply (control block already stripped)
assistant_msg = PrompterMessageTable(
id=uuid4(),
session_id=session_id,
@@ -237,8 +291,11 @@ class PrompterService:
draft_ready=llm_reply["draft_ready"],
)
# Return all messages in order
return await self._load_messages(session_id)
return TurnResult(
messages=await self._load_messages(session_id),
draft_ready=bool(llm_reply["draft_ready"]),
scale=llm_reply.get("scale"),
)
async def get_or_generate_draft(
self,
@@ -302,52 +359,21 @@ class PrompterService:
session_rec = await self._get_session(session_id, agent_id)
ov = confirm_overrides or ConfirmOverrides()
# Get or generate the draft, then merge confirm-time overrides
# Get or generate the draft. A human-edited structured draft, if passed,
# replaces the stored one before overrides and re-composition.
draft_record = await self.get_or_generate_draft(session_id, agent_id)
draft_data: dict[str, Any] = dict(draft_record.draft_data)
if ov.draft is not None:
draft_data: dict[str, Any] = dict(ov.draft)
draft_data["source"] = "prompter"
draft_data["confirmed_by_human"] = False
else:
draft_data = dict(draft_record.draft_data)
self._apply_overrides(draft_data, ov)
resolved_project_id = self._resolve_uuid_field(draft_data, "project_id")
resolved_product_id = self._resolve_uuid_field(draft_data, "product_id")
if resolved_project_id is None and resolved_product_id is None:
raise ValidationError(
message=(
"The draft must have either project_id or product_id set. "
"Pass one via the confirm request body."
),
field="project_id",
)
task = await self.create_task_from_draft(draft_data, agent_id)
team, task_type, nature, complexity = self._coerce_draft_enums(draft_data)
# Resolve assigned_to as UUID if possible
resolved_assigned_to: UUID | None = None
if draft_data.get("assigned_to"):
with contextlib.suppress(ValueError):
resolved_assigned_to = UUID(str(draft_data["assigned_to"]))
req = TaskCreateRequest(
title=draft_data["title"],
description=draft_data["description"],
acceptance_criteria=draft_data["acceptance_criteria"],
team=team,
created_by=agent_id,
task_type=task_type,
nature=nature,
estimated_complexity=complexity,
priority=int(draft_data.get("priority", 2)),
assigned_to=resolved_assigned_to,
project_id=resolved_project_id,
product_id=resolved_product_id,
source="prompter",
confirmed_by_human=True,
)
# Import TaskService lazily to avoid circular imports
from roboco.services.task import get_task_service
task_service = get_task_service(self._session)
task: TaskTable = await task_service.create(req)
# Persist the launched draft so the stored record reflects reality.
draft_record.draft_data = draft_data
# Mark draft as confirmed
now = datetime.now(UTC)
@@ -361,7 +387,139 @@ class PrompterService:
session_id=str(session_id),
task_id=str(task.id),
)
return task.id # type: ignore[return-value]
return UUID(str(task.id))
async def create_task_from_draft(
self,
draft_data: dict[str, Any],
agent_id: UUID,
*,
status: TaskStatus = TaskStatus.BACKLOG,
assigned_to: UUID | None = None,
) -> TaskTable:
"""Create a Task from a structured draft.
Shared by both prompter confirm paths (``confirm_draft`` and the
live-intake ``confirm_live_draft``): recomposes the description,
validates exactly-one target, coerces enums, routes the owning team
(product Main PM, project lead cell), and persists via
``TaskService.create``. Mutates ``draft_data['description']`` in place.
``confirmed_by_human=True`` the CEO confirmed it.
``status`` defaults to ``BACKLOG`` (legacy ``confirm_draft`` behaviour).
The live-intake buttons pass ``PENDING`` + an ``assigned_to`` (a board
agent for "Board review & Start", main-pm for "Approve & Start") so the
task starts immediately on the chosen review path. An explicit
``assigned_to`` wins over any assignee carried on the draft.
"""
# Recompose the description from the (possibly edited) structured fields —
# the task always carries a freshly-composed, consistent description.
draft_data["description"] = compose_description(draft_data)
resolved_project_id = self._resolve_uuid_field(draft_data, "project_id")
resolved_product_id = self._resolve_uuid_field(draft_data, "product_id")
if resolved_project_id is None and resolved_product_id is None:
raise ValidationError(
message=(
"The draft must target a project (single-cell) or a product "
"(board-led, multi-cell). Pick one in the confirm step."
),
field="project_id",
)
if resolved_project_id is not None and resolved_product_id is not None:
raise ValidationError(
message="Set exactly one of project_id or product_id, not both.",
field="product_id",
)
_lead, task_type, nature, complexity = self._coerce_draft_enums(draft_data)
# Adaptive routing: a product target is a board-led coordination root
# owned by the Main PM (who fans out per cell); a project target is a
# single-cell executable task owned by the cell doing the work.
if resolved_product_id is not None:
team = Team.MAIN_PM
else:
team = self._lead_cell_team(draft_data, default=_lead)
# Explicit assignment (from the confirm button) wins; else fall back to
# any assignee carried on the draft.
resolved_assigned_to: UUID | None = assigned_to
if resolved_assigned_to is None and draft_data.get("assigned_to"):
with contextlib.suppress(ValueError):
resolved_assigned_to = UUID(str(draft_data["assigned_to"]))
req = TaskCreateRequest(
title=draft_data["title"],
description=draft_data["description"],
acceptance_criteria=draft_data["acceptance_criteria"],
team=team,
created_by=agent_id,
task_type=task_type,
nature=nature,
estimated_complexity=complexity,
priority=self._coerce_priority(draft_data.get("priority")),
assigned_to=resolved_assigned_to,
project_id=resolved_project_id,
product_id=resolved_product_id,
status=status,
source="prompter",
confirmed_by_human=True,
)
# Import TaskService lazily to avoid circular imports
from roboco.services.task import get_task_service
task_service = get_task_service(self._session)
return await task_service.create(req)
async def confirm_live_draft(
self,
draft: dict[str, Any],
agent_id: UUID,
*,
project_id: UUID | None = None,
product_id: UUID | None = None,
route: Literal["board", "main_pm"] = "board",
) -> UUID:
"""Confirm a live-intake draft → create + start the task; return its id.
The human picked one of two start buttons (``route``):
- ``"board"`` ("Board review & Start") task at PENDING assigned to the
Product Owner, so the orchestrator dispatches the full Board review
(PO + Head of Marketing) before it reaches the Main PM.
- ``"main_pm"`` ("Approve & Start") task at PENDING assigned to the Main
PM, who delegates to the cells directly (Board review skipped).
Enum fields the dialog doesn't surface default to sane values so a
confirm never fails on a missing ``nature``.
"""
from roboco.seeds.initial_data import AGENT_UUIDS
draft_data: dict[str, Any] = dict(draft)
if project_id is not None:
draft_data["project_id"] = str(project_id)
if product_id is not None:
draft_data["product_id"] = str(product_id)
# Fields the confirm dialog doesn't expose — default rather than reject.
draft_data.setdefault("task_type", TaskType.CODE.value)
draft_data.setdefault("nature", TaskNature.TECHNICAL.value)
draft_data.setdefault("estimated_complexity", Complexity.MEDIUM.value)
draft_data.setdefault("priority", 2)
assignee_slug = "product-owner" if route == "board" else "main-pm"
assigned_to = UUID(AGENT_UUIDS[assignee_slug])
task = await self.create_task_from_draft(
draft_data, agent_id, status=TaskStatus.PENDING, assigned_to=assigned_to
)
self.log.info(
"Live intake draft confirmed — task started",
task_id=str(task.id),
route=route,
assigned_to=assignee_slug,
)
return UUID(str(task.id))
@staticmethod
def _apply_overrides(draft_data: dict[str, Any], ov: ConfirmOverrides) -> None:
@@ -389,23 +547,80 @@ class PrompterService:
field=key,
) from exc
@staticmethod
def _lead_cell_team(draft_data: dict[str, Any], default: Team) -> Team:
"""Owner of a single-cell task: first *valid* cell in the_work, else default.
Skips cell names that aren't valid ``Team`` values rather than raising —
the intake agent is an LLM and can emit an off-enum cell name.
"""
for raw in _cell_teams(draft_data.get("the_work") or []):
try:
return Team(raw)
except ValueError:
continue
return default
@staticmethod
def _coerce_draft_enums(
draft_data: dict[str, Any],
) -> tuple[Team, TaskType, TaskNature, Complexity]:
"""Coerce the draft's required enum fields, raising on missing/invalid."""
"""Coerce the draft's enum fields to valid values; default on invalid/missing.
The intake agent is an LLM and will occasionally emit an off-enum value
(e.g. ``task_type="feature"``, which is not a ``TaskType``). The
confirm/launch action must NEVER hard-fail on a cosmetic enum guess that
forces the agent to self-correct in-chat, which is unacceptable UX. Coerce
to a sane default instead; ``team`` falls back to the lead cell, then backend.
"""
try:
return (
Team(draft_data["team"]),
TaskType(draft_data["task_type"]),
TaskNature(draft_data["nature"]),
Complexity(draft_data["estimated_complexity"]),
)
except (KeyError, ValueError) as exc:
raise ValidationError(
message=f"Draft has invalid or missing required fields: {exc}",
field="draft",
) from exc
team = Team(draft_data["team"])
except (KeyError, ValueError, TypeError):
team = PrompterService._lead_cell_team(draft_data, Team.BACKEND)
try:
task_type = TaskType(draft_data["task_type"])
except (KeyError, ValueError, TypeError):
task_type = TaskType.CODE
try:
nature = TaskNature(draft_data["nature"])
except (KeyError, ValueError, TypeError):
nature = TaskNature.TECHNICAL
try:
complexity = Complexity(draft_data["estimated_complexity"])
except (KeyError, ValueError, TypeError):
complexity = Complexity.MEDIUM
return team, task_type, nature, complexity
@staticmethod
def _coerce_priority(value: Any) -> int:
"""Coerce the draft's priority to a valid int (0=urgent … 3=low).
priority is the one non-enum field the intake agent guesses, and it
guesses a word ("high") as often as a number. Map the words, clamp
numbers to 0-3, and default to 2 (medium) on anything unrecognized so the
launch never crashes on a priority guess (it did: ``int("high")``).
"""
words = {
"urgent": 0,
"critical": 0,
"high": 1,
"medium": 2,
"normal": 2,
"low": 3,
}
if isinstance(value, bool):
return 2
if isinstance(value, int):
return min(max(value, 0), 3)
if isinstance(value, str):
key = value.strip().lower()
if key in words:
return words[key]
try:
return min(max(int(key), 0), 3)
except ValueError:
return 2
return 2
# -----------------------------------------------------------------------
# Private helpers (session-based)
@@ -420,7 +635,7 @@ class PrompterService:
)
rec = result.scalar_one_or_none()
if rec is None:
raise NotFoundError(f"Prompter session {session_id} not found")
raise NotFoundError("Prompter session", str(session_id))
if rec.agent_id != agent_id:
raise ServiceError(
f"Session {session_id} does not belong to agent {agent_id}"
@@ -436,6 +651,48 @@ class PrompterService:
)
return list(result.scalars().all())
async def _assemble_live_context(self) -> str | None:
"""Build a compact 'Available projects / products' block for the interview.
Grounds the assistant in the real targets the human can launch against,
so it references existing surfaces and can resolve project/product
itself. Best-effort: a lookup failure degrades to no context rather than
breaking the chat. Returns None when nothing is registered.
"""
if self._db is None:
return None
from roboco.services.product import get_product_service
from roboco.services.project import get_project_service
lines: list[str] = []
try:
projects = await get_project_service(self._session).list_all(
active_only=True, limit=50
)
except Exception as exc:
self.log.warning("Live project list unavailable", error=str(exc))
projects = []
if projects:
lines.append("Available projects (single-cell tasks target one of these):")
lines.extend(f" - {p.name} (slug: {p.slug}, id: {p.id})" for p in projects)
try:
products = await get_product_service(self._session).list_all(limit=50)
except Exception as exc:
self.log.warning("Live product list unavailable", error=str(exc))
products = []
if products:
lines.append(
"Available products (board-led multi-cell features target one "
"of these):"
)
lines.extend(
f" - {pr.name} (slug: {pr.slug}, id: {pr.id})" for pr in products
)
return "\n".join(lines) if lines else None
# -----------------------------------------------------------------------
# Shared LLM helpers
# -----------------------------------------------------------------------
@@ -445,9 +702,14 @@ class PrompterService:
messages: list[dict[str, str]],
context: dict[str, Any] | None = None,
max_tokens: int = 2048,
live_context: str | None = None,
) -> dict[str, Any]:
"""Call the LLM for a chat response. Returns {message, draft_ready}."""
user_prompt = _build_chat_prompt(messages, context)
"""Call the LLM for a chat response.
Returns ``{message, draft_ready, scale}`` where ``message`` is the
user-visible reply with the trailing roboco-meta control block stripped.
"""
user_prompt = _build_chat_prompt(messages, context, live_context)
try:
content = await self._create_message(
messages=[
@@ -463,9 +725,14 @@ class PrompterService:
if not content:
raise ServiceError("LLM returned empty content")
clean, tag = parse_readiness(content)
# If the model omitted the control block, fall back to the clean text
# so the user still sees a reply rather than an empty bubble.
message = clean or content
return {
"message": content,
"draft_ready": _detect_draft_ready(content),
"message": message,
"draft_ready": bool(tag and tag.ready),
"scale": tag.scale if tag else None,
}
async def _llm_draft(
@@ -502,6 +769,9 @@ class PrompterService:
draft_data["source"] = "prompter"
draft_data["confirmed_by_human"] = False
# Compose the markdown description from the structured fields — the model
# never hand-formats it, so the description is always consistent.
draft_data["description"] = compose_description(draft_data)
return {
"draft": draft_data,
"reasoning": _build_reasoning(messages, draft_data),
@@ -546,8 +816,12 @@ class PrompterService:
def _build_chat_prompt(
messages: list[dict[str, str]],
context: dict[str, Any] | None,
live_context: str | None = None,
) -> str:
lines: list[str] = []
if live_context:
lines.append(live_context)
lines.append("")
if context:
lines.append("Context:")
for key, value in context.items():
@@ -560,9 +834,9 @@ def _build_chat_prompt(
lines.append(f"{role}: {content}")
lines.append("")
lines.append(
"Continue the conversation as the Prompter assistant. "
"If you have enough information to draft a complete task, "
"say so explicitly."
"Continue the conversation as the Prompter assistant. End with the "
"roboco-meta control block. If you can write a complete task spec now, "
"set ready to true."
)
return "\n".join(lines)
@@ -604,17 +878,145 @@ def _strip_code_fences(content: str) -> str:
return text.strip()
def _detect_draft_ready(content: str) -> bool:
signals = [
"i have enough information",
"ready to generate a draft",
"ready to draft",
"i can now draft",
"draft_ready=true",
"draft ready",
]
lower = content.lower()
return any(sig in lower for sig in signals)
_META_FENCE_RE = re.compile(r"```roboco-meta\s*(.*?)```", re.DOTALL)
# Mirror of PrompterDraftTask.description min_length — below this the composed
# body is too thin to be a valid task, so we fall back to any provided text.
_MIN_DESCRIPTION_LEN = 20
_TEAM_LABELS: dict[str, str] = {
"backend": "Backend",
"frontend": "Frontend",
"ux_ui": "UX/UI",
"main_pm": "Main PM",
"board": "Board",
}
def parse_readiness(content: str) -> tuple[str, ReadinessTag | None]:
"""Split an assistant reply into (clean_text, readiness_tag).
The interview prompt instructs the model to end each turn with a fenced
``roboco-meta`` JSON block. This extracts the last such block, strips it
from the user-visible text, and parses it. A missing or malformed block
yields ``None`` (treated as not-ready) so the conversation never breaks.
"""
matches = list(_META_FENCE_RE.finditer(content))
if not matches:
return content.strip(), None
# Strip every control block from the visible text (a well-behaved model
# emits one; remove any strays too), and read readiness from the last.
clean = _META_FENCE_RE.sub("", content).strip()
try:
data = json.loads(matches[-1].group(1).strip())
except (json.JSONDecodeError, ValueError):
return clean, None
if not isinstance(data, dict):
return clean, None
raw_scale = data.get("scale")
scale = str(raw_scale) if raw_scale in ("single", "multi") else None
covered = [str(c) for c in data.get("covered") or [] if isinstance(c, str)]
return clean, ReadinessTag(
covered=covered,
ready=bool(data.get("ready", False)),
scale=scale,
)
def _cell_teams(the_work: list[dict[str, Any]]) -> list[str]:
"""Distinct cell teams (backend/frontend/ux_ui) present in the_work, in order."""
cell_values = {t.value for t in CELL_TEAMS}
seen: list[str] = []
for entry in the_work:
team = str(entry.get("team", ""))
if team in cell_values and team not in seen:
seen.append(team)
return seen
def derive_scale(the_work: list[dict[str, Any]]) -> str:
"""'multi' when more than one cell participates, else 'single'."""
return "multi" if len(_cell_teams(the_work)) > 1 else "single"
def _clean_list(value: Any) -> list[str]:
"""Trimmed, non-empty string items from a possibly-missing list field."""
return [str(i).strip() for i in (value or []) if str(i).strip()]
def _text(value: Any) -> str:
"""Trimmed string from a possibly-missing scalar field."""
return str(value or "").strip()
def _bullets(items: list[str]) -> str:
"""Render a markdown bullet list."""
return "\n".join(f"- {i}" for i in items)
def _cell_label(team: str) -> str:
"""Display label for a team value."""
return _TEAM_LABELS.get(team) or team.replace("_", " ").title() or "Work"
def _render_work_entry(entry: dict[str, Any]) -> str:
"""Render one cell's slice: a bold heading and its deliverables."""
head = f"**{_cell_label(_text(entry.get('team')))}**"
summary = _text(entry.get("summary"))
if summary:
head = f"{head}{summary}"
items = _clean_list(entry.get("items"))
return f"{head}\n{_bullets(items)}" if items else head
def _render_the_work(the_work: list[dict[str, Any]]) -> str:
"""Render The Work section, with a board-led lead line when multi-cell."""
blocks = [_render_work_entry(e) for e in the_work]
if len(_cell_teams(the_work)) > 1:
blocks.insert(
0,
"Board-led: the Board sets requirements and the Main PM "
"delegates one subtask per cell.",
)
return "\n\n".join(blocks)
def _section(sections: list[str], heading: str, body: str) -> None:
"""Append a markdown section when its body is non-empty."""
if body:
sections.append(f"## {heading}\n\n{body}")
def compose_description(draft: dict[str, Any]) -> str:
"""Build the markdown description deterministically from structured fields.
Sections present only when their field has content. ``acceptance_criteria``
renders under Success Criteria. A multi-cell task gets a board-led lead
line. Falls back to any model-provided ``description`` if the structured
fields are too sparse to clear the schema's 20-char minimum.
"""
the_work = draft.get("the_work") or []
sections: list[str] = []
_section(sections, "Objective", _text(draft.get("objective")))
_section(
sections,
"What This Builds",
_bullets(_clean_list(draft.get("what_this_builds"))),
)
_section(sections, "The Work", _render_the_work(the_work) if the_work else "")
_section(sections, "Notes", _bullets(_clean_list(draft.get("notes"))))
_section(
sections,
"Success Criteria",
_bullets(_clean_list(draft.get("acceptance_criteria"))),
)
composed = "\n\n".join(sections).strip()
if len(composed) >= _MIN_DESCRIPTION_LEN:
return composed
return _text(draft.get("description")) or composed
def _build_reasoning(
+148
View File
@@ -0,0 +1,148 @@
"""Live intake-session relay — the orchestrator side of the chat bridge.
A live intake session is one spawned ``prompter`` container the CEO is chatting
with. This registry connects three flows for that session, all in-process (the
orchestrator is single-process and already holds container state in memory):
- **agent -> panel:** the container's driver POSTs each normalized
``StreamChunk`` to the relay endpoint, which ``push()``es it onto the
session's queue; the SSE endpoint ``stream()``s the queue to the browser.
- **panel -> agent:** the message endpoint ``deliver()``s the human's text to
the container's in-process receiver over HTTP.
- **lifecycle:** ``open`` on spawn, ``close`` on reap (draft confirmed / idle);
``close`` unblocks any open stream with a sentinel.
This module owns no SDK or Claude code it's pure plumbing, fully unit-tested.
"""
from __future__ import annotations
import asyncio
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any
import httpx
import structlog
if TYPE_CHECKING:
from collections.abc import AsyncIterator
logger = structlog.get_logger()
# The in-container receiver port (same ROBOCO_SDK_PORT every agent sidecar uses).
SDK_PORT = 9000
# Sentinel pushed onto a session's queue to end its SSE stream.
_CLOSE = object()
@dataclass
class LiveIntakeSession:
"""One live chat: a queue of outbound events + the container to deliver to."""
session_id: str
agent_id: str # container agent id, e.g. "intake-3f9c1a2b"
queue: asyncio.Queue[Any] = field(default_factory=asyncio.Queue)
closed: bool = False
class PrompterLiveRegistry:
"""Tracks live intake sessions and bridges panel <-> container."""
def __init__(self, *, http_client: httpx.AsyncClient | None = None) -> None:
self._sessions: dict[str, LiveIntakeSession] = {}
self._client = http_client
self.log = logger.bind(component="prompter_live")
# -- lifecycle ---------------------------------------------------------
def open(self, session_id: str, agent_id: str) -> LiveIntakeSession:
"""Register a live session (called when its container is spawned).
Idempotent: if a live (un-closed) session already exists for this id,
return it unchanged instead of replacing it with a fresh queue. A second
``open`` would otherwise orphan the SSE stream ``stream()`` captures the
session's queue once when the browser connects, so swapping in a new queue
strands the browser on the old one while the agent's events push to the
new one (the panel then shows nothing despite the agent replying).
"""
existing = self._sessions.get(session_id)
if existing is not None and not existing.closed:
return existing
session = LiveIntakeSession(session_id=session_id, agent_id=agent_id)
self._sessions[session_id] = session
self.log.info(
"Live intake session opened", session_id=session_id, agent_id=agent_id
)
return session
def get(self, session_id: str) -> LiveIntakeSession | None:
return self._sessions.get(session_id)
def close(self, session_id: str) -> None:
"""End a live session and unblock its stream (called on reap)."""
session = self._sessions.pop(session_id, None)
if session is None:
return
session.closed = True
session.queue.put_nowait(_CLOSE)
self.log.info("Live intake session closed", session_id=session_id)
# -- agent -> panel ----------------------------------------------------
def push(self, session_id: str, event: dict[str, Any]) -> bool:
"""Queue one agent event for the SSE stream. False if no such session."""
session = self._sessions.get(session_id)
if session is None or session.closed:
return False
session.queue.put_nowait(event)
return True
async def stream(self, session_id: str) -> AsyncIterator[dict[str, Any]]:
"""Yield queued agent events until the session is closed."""
session = self._sessions.get(session_id)
if session is None:
return
while True:
item = await session.queue.get()
if item is _CLOSE:
return
yield item
# -- panel -> agent ----------------------------------------------------
async def deliver(self, session_id: str, text: str) -> bool:
"""Deliver the human's message to the container's receiver. False if gone."""
session = self._sessions.get(session_id)
if session is None or session.closed:
return False
url = f"http://roboco-agent-{session.agent_id}:{SDK_PORT}/turn"
client = self._client or httpx.AsyncClient(timeout=10.0)
try:
resp = await client.post(url, json={"text": text})
resp.raise_for_status()
return True
except Exception as exc:
# Debug, not error: the opening-message delivery retries until the
# container's receiver is up, so transient failures here are expected
# and were spamming ERROR. Callers surface a real failure (the
# /messages route 404s; _deliver_when_ready warns once after N tries).
self.log.debug(
"Message delivery attempt failed", session_id=session_id, error=str(exc)
)
return False
finally:
if self._client is None:
await client.aclose()
# Process-wide singleton — the orchestrator owns one registry. Held on a class
# (mirrors events/stream_bus._StreamEventBusHolder) to avoid a `global`.
class _RegistryHolder:
instance: PrompterLiveRegistry | None = None
def get_live_registry() -> PrompterLiveRegistry:
"""Return the process-wide live-session registry."""
if _RegistryHolder.instance is None:
_RegistryHolder.instance = PrompterLiveRegistry()
return _RegistryHolder.instance
+2
View File
@@ -20,6 +20,7 @@ def test_role_enum_has_every_role_inc_system() -> None:
"product_owner",
"head_marketing",
"auditor",
"prompter",
"ceo",
"system",
}
@@ -80,6 +81,7 @@ def test_agents_catalog_has_all_seed_slugs() -> None:
"product-owner",
"head-marketing",
"auditor",
"intake-1",
}
actual = set(identity.AGENTS.keys())
assert actual == expected_slugs, f"agent catalog drift: {actual ^ expected_slugs}"
+1
View File
@@ -31,6 +31,7 @@ def test_role_enum_has_every_pre_gateway_role() -> None:
"product_owner",
"head_marketing",
"auditor",
"prompter", # post-gateway intake role (human-only, drafts tasks)
"ceo",
"system",
}
@@ -0,0 +1,332 @@
"""Integration tests for the live intake chat routes (start/stop + relay + msg).
The SSE stream generator itself is unit-tested at the service layer
(``test_prompter_live``); here we exercise the HTTP contracts against an
injected registry whose container deliveries hit a mocked transport, and the
start/stop routes against a fake orchestrator.
"""
from __future__ import annotations
from http import HTTPStatus
from types import SimpleNamespace
from typing import TYPE_CHECKING, Any
from unittest.mock import patch
from uuid import uuid4
import httpx
import pytest
import pytest_asyncio
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from roboco.api import deps
from roboco.api.deps import get_agent_context
from roboco.api.routes.prompter_live import router
from roboco.db.base import get_db
from roboco.models.base import AgentRole
from roboco.services import prompter_live
from roboco.services.base import ValidationError
from roboco.services.permissions import AgentContext
if TYPE_CHECKING:
from collections.abc import AsyncIterator
@pytest_asyncio.fixture
async def live_client() -> AsyncIterator[dict[str, Any]]:
def container_handler(_req: httpx.Request) -> httpx.Response:
return httpx.Response(200, json={"ok": True})
mock_client = httpx.AsyncClient(transport=httpx.MockTransport(container_handler))
registry = prompter_live.PrompterLiveRegistry(http_client=mock_client)
prompter_live._RegistryHolder.instance = registry # inject the singleton
app = FastAPI()
app.include_router(router, prefix="/api/prompter")
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
yield {"client": client, "registry": registry}
prompter_live._RegistryHolder.instance = None
await mock_client.aclose()
@pytest.mark.asyncio
async def test_relay_event_pushes_to_live_session(live_client: dict) -> None:
client, registry = live_client["client"], live_client["registry"]
registry.open("s1", "intake-1")
resp = await client.post(
"/api/prompter/live/s1/events", json={"kind": "text", "text": "hi"}
)
assert resp.status_code == HTTPStatus.OK
assert resp.json() == {"pushed": True}
# The event is now on the session's queue.
assert registry.get("s1").queue.qsize() == 1
@pytest.mark.asyncio
async def test_relay_event_unknown_session_is_noop(live_client: dict) -> None:
resp = await live_client["client"].post(
"/api/prompter/live/nope/events", json={"kind": "text"}
)
assert resp.status_code == HTTPStatus.OK
assert resp.json() == {"pushed": False}
@pytest.mark.asyncio
async def test_send_message_delivers_to_container(live_client: dict) -> None:
client, registry = live_client["client"], live_client["registry"]
registry.open("s1", "intake-1")
resp = await client.post(
"/api/prompter/live/s1/messages", json={"text": "hello there"}
)
assert resp.status_code == HTTPStatus.OK
assert resp.json() == {"delivered": True}
@pytest.mark.asyncio
async def test_send_message_unknown_session_404(live_client: dict) -> None:
resp = await live_client["client"].post(
"/api/prompter/live/nope/messages", json={"text": "hi"}
)
assert resp.status_code == HTTPStatus.NOT_FOUND
@pytest.mark.asyncio
async def test_send_message_requires_text(live_client: dict) -> None:
live_client["registry"].open("s1", "intake-1")
resp = await live_client["client"].post(
"/api/prompter/live/s1/messages", json={"text": ""}
)
assert resp.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
# ---------------------------------------------------------------------------
# start / stop — spawn + reap against a fake orchestrator (no docker).
# ---------------------------------------------------------------------------
class _FakeOrchestrator:
"""Records spawn/reap calls; stands in for the real orchestrator singleton."""
def __init__(self) -> None:
self.spawned: list[dict[str, Any]] = []
self.reaped: list[str] = []
async def start_intake_session(
self,
session_id: str,
*,
project_slug: str | None = None,
product_id: str | None = None,
initial_message: str | None = None,
) -> None:
# The route is non-blocking now: it calls start_intake_session (returns
# None) which opens the relay + spawns in the background.
self.spawned.append(
{
"session_id": session_id,
"project_slug": project_slug,
"product_id": product_id,
"initial_message": initial_message,
}
)
async def reap_intake_session(self, session_id: str) -> None:
self.reaped.append(session_id)
@pytest_asyncio.fixture
async def start_client(
monkeypatch: pytest.MonkeyPatch,
) -> AsyncIterator[dict[str, Any]]:
orch = _FakeOrchestrator()
# monkeypatch.setattr is untyped (no ignore for the fake) and auto-reverts.
monkeypatch.setattr(deps._ServiceHolder, "orchestrator", orch)
async def _fake_db() -> AsyncIterator[object]:
yield object() # product-scope start never touches it
app = FastAPI()
app.include_router(router, prefix="/api/prompter")
app.dependency_overrides[get_db] = _fake_db
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
yield {"client": client, "orch": orch}
@pytest.mark.asyncio
async def test_start_product_scope_spawns_and_returns_session(
start_client: dict,
) -> None:
client, orch = start_client["client"], start_client["orch"]
product_id = str(uuid4())
resp = await client.post(
"/api/prompter/live/start",
json={"product_id": product_id, "initial_message": "build X"},
)
assert resp.status_code == HTTPStatus.CREATED
session_id = resp.json()["session_id"]
assert session_id
assert orch.spawned == [
{
"session_id": session_id,
"project_slug": None,
"product_id": product_id,
"initial_message": "build X",
}
]
@pytest.mark.asyncio
async def test_start_project_scope_resolves_slug(start_client: dict) -> None:
client, orch = start_client["client"], start_client["orch"]
project_id = uuid4()
fake_svc = SimpleNamespace(
get=lambda _pid: _async_return(SimpleNamespace(slug="roboco"))
)
with patch("roboco.services.project.get_project_service", lambda _db: fake_svc):
resp = await client.post(
"/api/prompter/live/start", json={"project_id": str(project_id)}
)
assert resp.status_code == HTTPStatus.CREATED
assert orch.spawned[0]["project_slug"] == "roboco"
assert orch.spawned[0]["product_id"] is None
@pytest.mark.asyncio
async def test_start_unknown_project_404(start_client: dict) -> None:
client = start_client["client"]
fake_svc = SimpleNamespace(get=lambda _pid: _async_return(None))
with patch("roboco.services.project.get_project_service", lambda _db: fake_svc):
resp = await client.post(
"/api/prompter/live/start", json={"project_id": str(uuid4())}
)
assert resp.status_code == HTTPStatus.NOT_FOUND
@pytest.mark.asyncio
async def test_start_requires_exactly_one_scope(start_client: dict) -> None:
client = start_client["client"]
both = await client.post(
"/api/prompter/live/start",
json={"project_id": str(uuid4()), "product_id": str(uuid4())},
)
assert both.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
neither = await client.post("/api/prompter/live/start", json={})
assert neither.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
@pytest.mark.asyncio
async def test_stop_reaps_session(start_client: dict) -> None:
client, orch = start_client["client"], start_client["orch"]
resp = await client.post("/api/prompter/live/sess-9/stop")
assert resp.status_code == HTTPStatus.OK
assert resp.json() == {"stopped": True}
assert orch.reaped == ["sess-9"]
def _async_return(value: Any) -> Any:
"""Wrap a value in an awaitable so a lambda can stand in for an async method."""
async def _coro() -> Any:
return value
return _coro()
# ---------------------------------------------------------------------------
# confirm — draft → task + reap-on-confirm (service mocked; route wiring only).
# ---------------------------------------------------------------------------
class _FakeDb:
async def commit(self) -> None:
return None
@pytest_asyncio.fixture
async def confirm_client(
monkeypatch: pytest.MonkeyPatch,
) -> AsyncIterator[dict[str, Any]]:
orch = _FakeOrchestrator()
monkeypatch.setattr(deps._ServiceHolder, "orchestrator", orch)
async def _fake_db() -> AsyncIterator[_FakeDb]:
yield _FakeDb()
ceo = AgentContext(agent_id=uuid4(), role=AgentRole.CEO, team=None, slug="ceo")
app = FastAPI()
app.include_router(router, prefix="/api/prompter")
app.dependency_overrides[get_db] = _fake_db
app.dependency_overrides[get_agent_context] = lambda: ceo
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
yield {"client": client, "orch": orch}
@pytest.mark.asyncio
async def test_confirm_creates_task_and_reaps(confirm_client: dict) -> None:
client, orch = confirm_client["client"], confirm_client["orch"]
task_id = uuid4()
class _FakeService:
async def confirm_live_draft(self, _draft: Any, _agent: Any, **_kw: Any) -> Any:
return task_id
with patch(
"roboco.api.routes.prompter_live.get_prompter_service",
lambda _db: _FakeService(),
):
resp = await client.post(
"/api/prompter/live/s1/confirm",
json={
"project_id": str(uuid4()),
"draft": {"title": "x", "acceptance_criteria": ["a"]},
},
)
assert resp.status_code == HTTPStatus.CREATED
assert resp.json() == {"task_id": str(task_id)}
assert orch.reaped == ["s1"] # reap-on-confirm
@pytest.mark.asyncio
async def test_confirm_requires_exactly_one_target(confirm_client: dict) -> None:
client = confirm_client["client"]
both = await client.post(
"/api/prompter/live/s1/confirm",
json={
"project_id": str(uuid4()),
"product_id": str(uuid4()),
"draft": {"title": "x"},
},
)
assert both.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
@pytest.mark.asyncio
async def test_confirm_validation_error_is_translated_and_not_reaped(
confirm_client: dict,
) -> None:
client, orch = confirm_client["client"], confirm_client["orch"]
class _FakeService:
async def confirm_live_draft(self, _draft: Any, _agent: Any, **_kw: Any) -> Any:
raise ValidationError(message="bad draft", field="title")
with patch(
"roboco.api.routes.prompter_live.get_prompter_service",
lambda _db: _FakeService(),
):
resp = await client.post(
"/api/prompter/live/s1/confirm",
json={"project_id": str(uuid4()), "draft": {"title": "x"}},
)
assert resp.status_code == HTTPStatus.BAD_REQUEST
assert orch.reaped == [] # a failed confirm must NOT reap the session
+109 -23
View File
@@ -25,6 +25,7 @@ from roboco.api.routes.prompter import router as prompter_router
from roboco.db.tables import AgentTable, ProjectTable
from roboco.models.base import AgentRole, AgentStatus, Team
from roboco.models.permissions import AgentContext
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
if TYPE_CHECKING:
from collections.abc import AsyncIterator
@@ -44,8 +45,9 @@ _DOUBLE_TURN_MSGS = 4 # 2 user + 2 assistant
async def prompter_client(
db_session: AsyncSession,
) -> AsyncIterator[dict[str, Any]]:
agent_id = uuid4()
agent = AgentTable(
id=uuid4(),
id=agent_id,
name="DevAgent",
slug=f"dev-agent-{uuid4().hex[:8]}",
role=AgentRole.DEVELOPER,
@@ -68,7 +70,7 @@ async def prompter_client(
async def _override_agent() -> AgentContext:
return AgentContext(
agent_id=agent.id, # type: ignore[arg-type]
agent_id=agent_id,
role=AgentRole.DEVELOPER,
team=None,
)
@@ -118,6 +120,94 @@ async def project_fixture(db_session: AsyncSession) -> ProjectTable:
_HDR = {"X-Agent-ID": "be-dev-1", "X-Agent-Role": "developer"}
@pytest_asyncio.fixture
async def cross_request_client(
_test_database_url: str,
) -> AsyncIterator[dict[str, Any]]:
"""Client whose DB dependency yields a fresh, NON-auto-committing session
per request.
This is the boundary the shared-session ``prompter_client`` fixture can't
exercise: here a write is only visible to the next request if the route
committed it explicitly. The seed agent is committed up front so both
requests can resolve it.
"""
engine = create_async_engine(_test_database_url, future=True, pool_pre_ping=True)
maker = async_sessionmaker(bind=engine, expire_on_commit=False)
agent_id = uuid4()
async with maker() as seed:
seed.add(
AgentTable(
id=agent_id,
name="XReqAgent",
slug=f"xreq-agent-{uuid4().hex[:8]}",
role=AgentRole.DEVELOPER,
team=None,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="dev",
capabilities=[],
permissions={},
metrics={},
)
)
await seed.commit()
app = FastAPI()
app.include_router(prompter_router, prefix="/api/prompter")
async def _override_db() -> AsyncIterator[AsyncSession]:
# A fresh session per request that does NOT commit on teardown, so
# persistence depends solely on the route's explicit commit.
async with maker() as session:
yield session
async def _override_agent() -> AgentContext:
return AgentContext(agent_id=agent_id, role=AgentRole.DEVELOPER, team=None)
app.dependency_overrides[get_db] = _override_db
app.dependency_overrides[get_agent_context] = _override_agent
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
yield {"client": client, "agent_id": agent_id}
app.dependency_overrides.clear()
await engine.dispose()
@pytest.mark.asyncio
async def test_session_persists_across_requests(cross_request_client: dict) -> None:
"""A created session must survive into the next request's own DB session.
Regression for the production 404: the create returned 201 but the session
write was never committed, so the immediately-following /messages call could
not find it. Without the route's explicit commit, this is a 404.
"""
client = cross_request_client["client"]
create = await client.post("/api/prompter/sessions", json={}, headers=_HDR)
assert create.status_code == HTTPStatus.CREATED
session_id = create.json()["id"]
reply = (
'ack\n```roboco-meta\n{"covered": [], "ready": false, "scale": "single"}\n```'
)
with patch(
"roboco.services.prompter.PrompterService._create_message",
new_callable=AsyncMock,
return_value=reply,
):
msg = await client.post(
f"/api/prompter/sessions/{session_id}/messages",
json={"content": "hello"},
headers=_HDR,
)
assert msg.status_code == HTTPStatus.OK, msg.json()
assert len(msg.json()["messages"]) == _SINGLE_TURN_MSGS
# =============================================================================
# Session-based endpoint tests
# =============================================================================
@@ -140,20 +230,6 @@ async def test_create_session_success(prompter_client: dict) -> None:
assert "created_at" in body
@pytest.mark.asyncio
async def test_create_session_with_context(prompter_client: dict) -> None:
"""POST /sessions accepts optional bootstrap context."""
client = prompter_client["client"]
response = await client.post(
"/api/prompter/sessions",
json={"context": {"team": "backend", "project_id": str(uuid4())}},
headers=_HDR,
)
assert response.status_code == HTTPStatus.CREATED
body = response.json()
assert body["status"] == "active"
@pytest.mark.asyncio
async def test_send_message_success(prompter_client: dict) -> None:
"""POST /sessions/{id}/messages appends user+assistant messages."""
@@ -177,24 +253,28 @@ async def test_send_message_success(prompter_client: dict) -> None:
)
assert response.status_code == HTTPStatus.OK
messages = response.json()
body = response.json()
messages = body["messages"]
assert len(messages) == _SINGLE_TURN_MSGS
roles = [m["role"] for m in messages]
assert "user" in roles
assert "assistant" in roles
assert messages[-1]["content"] == "Great! Let's gather requirements."
assert body["draft_ready"] is False
@pytest.mark.asyncio
async def test_send_message_marks_draft_ready(prompter_client: dict) -> None:
"""draft_ready signal in LLM response updates session status."""
"""A ready roboco-meta control block flips draft_ready and session status."""
client = prompter_client["client"]
session_resp = await client.post("/api/prompter/sessions", json={}, headers=_HDR)
session_id = session_resp.json()["id"]
mock_response = (
"I have enough information to draft a task now. Ready to draft when you are."
"Understood — I have what I need.\n\n"
'```roboco-meta\n{"covered": ["objective", "scope", "surface", '
'"acceptance"], "ready": true, "scale": "single"}\n```'
)
with patch(
@@ -209,8 +289,12 @@ async def test_send_message_marks_draft_ready(prompter_client: dict) -> None:
)
assert response.status_code == HTTPStatus.OK
messages = response.json()
assert len(messages) == _SINGLE_TURN_MSGS
body = response.json()
assert len(body["messages"]) == _SINGLE_TURN_MSGS
assert body["draft_ready"] is True
assert body["scale"] == "single"
# The control block must not leak into the persisted assistant message.
assert "roboco-meta" not in body["messages"][-1]["content"]
@pytest.mark.asyncio
@@ -500,7 +584,7 @@ async def test_full_happy_path(
headers=_HDR,
)
assert step2b.status_code == HTTPStatus.OK
messages = step2b.json()
messages = step2b.json()["messages"]
assert len(messages) == _DOUBLE_TURN_MSGS
# Step 3: Get draft
@@ -578,7 +662,9 @@ async def test_prompter_chat_draft_ready(prompter_client: dict) -> None:
client = prompter_client["client"]
mock_response = (
"I have enough information. draft_ready=true. Ready to generate a draft."
"Understood.\n\n"
'```roboco-meta\n{"covered": ["objective", "scope", "surface", '
'"acceptance"], "ready": true, "scale": "single"}\n```'
)
with patch(
+267
View File
@@ -0,0 +1,267 @@
"""Unit tests for the intake driver loop + event normalization.
SDK-free: the `claude-agent-sdk` message types are stood in by tiny fakes named
the same way `normalize` keys off (`StreamEvent`, `AssistantMessage`, ...), and
the driver loop runs against a fake session/source/sink. The real
`SdkIntakeSession` adapter needs the live `claude` binary and is excluded from
coverage.
"""
from __future__ import annotations
from contextlib import asynccontextmanager
from typing import TYPE_CHECKING
import pytest
from roboco.agent_sdk.intake_driver import (
IntakeDriver,
StreamChunk,
normalize,
)
if TYPE_CHECKING:
from collections.abc import AsyncIterator
# ---------------------------------------------------------------------------
# Fakes mirroring the claude-agent-sdk message/block shapes
# ---------------------------------------------------------------------------
class StreamEvent:
def __init__(self, event: dict) -> None:
self.event = event
class AssistantMessage:
def __init__(self, content: list) -> None:
self.content = content
class ResultMessage:
def __init__(self, session_id: str, total_cost_usd: float | None = None) -> None:
self.session_id = session_id
self.total_cost_usd = total_cost_usd
class SystemMessage:
def __init__(self, subtype: str) -> None:
self.subtype = subtype
class TextBlock:
def __init__(self, text: str) -> None:
self.text = text
class ThinkingBlock:
def __init__(self, thinking: str) -> None:
self.thinking = thinking
class ToolUseBlock:
def __init__(self, name: str, tool_input: dict) -> None:
self.name = name
self.input = tool_input
# ---------------------------------------------------------------------------
# normalize()
# ---------------------------------------------------------------------------
def test_normalize_stream_event_text_delta() -> None:
msg = StreamEvent({"delta": {"type": "text_delta", "text": "hel"}})
chunks = normalize(msg)
assert chunks == [StreamChunk(kind="text", text="hel")]
def test_normalize_stream_event_non_text_delta_is_dropped() -> None:
assert normalize(StreamEvent({"delta": {"type": "input_json_delta"}})) == []
assert normalize(StreamEvent({})) == []
def test_normalize_assistant_message_blocks() -> None:
# Text is NOT re-emitted from the AssistantMessage (the StreamEvent deltas
# already carried it live) — only thinking + tool_use, which have no deltas.
msg = AssistantMessage(
[
TextBlock("hello"),
ThinkingBlock("hmm"),
ToolUseBlock("Read", {"file": "metrics.tsx"}),
]
)
chunks = normalize(msg)
assert [c.kind for c in chunks] == ["thinking", "tool_use"]
assert chunks[0].text == "hmm"
assert chunks[1].tool == "Read"
assert chunks[1].data["input"] == {"file": "metrics.tsx"}
def test_normalize_assistant_message_extracts_draft_block() -> None:
# A finished reply that ends with a fenced roboco-draft block yields a
# single `draft` chunk carrying the parsed object — and no `text` chunk.
text = (
"Here is the task.\n"
"```roboco-draft\n"
'{"title": "Add metrics", "acceptance_criteria": ["x"], "scale": "single"}\n'
"```\n"
)
chunks = normalize(AssistantMessage([TextBlock(text)]))
assert [c.kind for c in chunks] == ["draft"]
assert chunks[0].data["title"] == "Add metrics"
assert chunks[0].data["scale"] == "single"
def test_normalize_assistant_message_malformed_draft_is_ignored() -> None:
bad = "```roboco-draft\n{not valid json}\n```"
assert normalize(AssistantMessage([TextBlock(bad)])) == []
# A draft block with no title is not a usable draft either.
no_title = '```roboco-draft\n{"acceptance_criteria": []}\n```'
assert normalize(AssistantMessage([TextBlock(no_title)])) == []
def test_normalize_propose_draft_tool_becomes_draft_chunk() -> None:
# The canonical signal: the agent CALLS propose_draft → one `draft` chunk
# (not a tool_use chunk).
msg = AssistantMessage(
[
ToolUseBlock(
"propose_draft",
{"draft": {"title": "Add metrics", "acceptance_criteria": ["x"]}},
)
]
)
chunks = normalize(msg)
assert [c.kind for c in chunks] == ["draft"]
assert chunks[0].data["title"] == "Add metrics"
def test_normalize_propose_draft_accepts_flat_input() -> None:
# Tolerant of the draft fields passed flat (no "draft" wrapper).
msg = AssistantMessage([ToolUseBlock("propose_draft", {"title": "Flat", "x": 1})])
chunks = normalize(msg)
assert [c.kind for c in chunks] == ["draft"]
assert chunks[0].data["title"] == "Flat"
def test_normalize_propose_draft_namespaced_name() -> None:
# However the SDK namespaces it (e.g. mcp__intake__propose_draft).
msg = AssistantMessage(
[ToolUseBlock("mcp__intake__propose_draft", {"draft": {"title": "NS"}})]
)
assert [c.kind for c in normalize(msg)] == ["draft"]
def test_normalize_other_tool_stays_tool_use() -> None:
chunks = normalize(AssistantMessage([ToolUseBlock("Read", {"file": "x.py"})]))
assert [c.kind for c in chunks] == ["tool_use"]
assert chunks[0].tool == "Read"
def test_normalize_propose_draft_without_title_is_ignored() -> None:
msg = AssistantMessage(
[ToolUseBlock("propose_draft", {"draft": {"acceptance_criteria": []}})]
)
assert normalize(msg) == []
def test_normalize_result_message_carries_session_id() -> None:
cost = 0.01
chunks = normalize(ResultMessage(session_id="sess-123", total_cost_usd=cost))
assert len(chunks) == 1
assert chunks[0].kind == "turn_end"
assert chunks[0].data["session_id"] == "sess-123"
assert chunks[0].data["cost_usd"] == cost
def test_normalize_system_message() -> None:
chunks = normalize(SystemMessage(subtype="init"))
assert chunks == [StreamChunk(kind="system", data={"subtype": "init"})]
def test_normalize_unknown_message_is_empty() -> None:
assert normalize(object()) == []
# ---------------------------------------------------------------------------
# IntakeDriver loop
# ---------------------------------------------------------------------------
class _FakeSession:
"""Scripts each input text to a list of chunks to stream back."""
def __init__(self, scripted: dict[str, list[StreamChunk]]) -> None:
self.scripted = scripted
self.seen: list[str] = []
async def send(self, text: str) -> AsyncIterator[StreamChunk]:
self.seen.append(text)
for chunk in self.scripted.get(text, []):
yield chunk
class _RaisingSession:
"""Streams one chunk, then fails mid-turn (faithful to a live SDK error)."""
async def send(self, _text: str) -> AsyncIterator[StreamChunk]:
yield StreamChunk(kind="text", text="partial")
raise RuntimeError("boom")
def _source(messages: list[str | None]):
queue = list(messages)
async def _next() -> str | None:
return queue.pop(0) if queue else None
return _next
@pytest.mark.asyncio
async def test_driver_streams_turns_until_shutdown() -> None:
session = _FakeSession(
{
"hi": [StreamChunk(kind="text", text="hello there")],
"more": [
StreamChunk(kind="tool_use", tool="Read"),
StreamChunk(kind="text", text="done"),
],
}
)
@asynccontextmanager
async def factory():
yield session
collected: list[StreamChunk] = []
async def emit(chunk: StreamChunk) -> None:
collected.append(chunk)
driver = IntakeDriver(factory, _source(["hi", "more", None]), emit)
await driver.run()
assert session.seen == ["hi", "more"] # stopped on None, did not call send(None)
assert [c.kind for c in collected] == ["text", "tool_use", "text"]
assert collected[0].text == "hello there"
@pytest.mark.asyncio
async def test_driver_turn_failure_emits_error_and_continues() -> None:
@asynccontextmanager
async def factory():
yield _RaisingSession()
collected: list[StreamChunk] = []
async def emit(chunk: StreamChunk) -> None:
collected.append(chunk)
driver = IntakeDriver(factory, _source(["boom-please", None]), emit)
await driver.run() # must not raise
# The partial chunk made it out, then the failure surfaced as an error chunk.
assert [c.kind for c in collected] == ["text", "error"]
assert collected[0].text == "partial"
assert "boom" in collected[1].text
+75
View File
@@ -0,0 +1,75 @@
"""Unit tests for the intake container entrypoint wiring helpers."""
from __future__ import annotations
import asyncio
import json
from http import HTTPStatus
import httpx
import pytest
from httpx import ASGITransport, AsyncClient
from roboco.agent_sdk.intake_driver import StreamChunk
from roboco.agent_sdk.intake_main import (
build_receiver,
make_message_source,
make_relay_sink,
)
@pytest.mark.asyncio
async def test_message_source_returns_queued_then_none() -> None:
queue: asyncio.Queue[str | None] = asyncio.Queue()
source = make_message_source(queue)
await queue.put("hi")
await queue.put(None)
assert await source() == "hi"
assert await source() is None # shutdown sentinel
@pytest.mark.asyncio
async def test_relay_sink_posts_chunk_to_orchestrator() -> None:
seen: dict[str, object] = {}
def handler(request: httpx.Request) -> httpx.Response:
seen["url"] = str(request.url)
seen["body"] = json.loads(request.content)
return httpx.Response(200)
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
sink = make_relay_sink("http://orch:8000", "sess-1", client)
await sink(StreamChunk(kind="text", text="hello"))
assert seen["url"] == "http://orch:8000/api/prompter/live/sess-1/events"
assert seen["body"] == {"kind": "text", "text": "hello", "tool": "", "data": {}}
await client.aclose()
@pytest.mark.asyncio
async def test_relay_sink_swallows_post_failure() -> None:
def boom(_request: httpx.Request) -> httpx.Response:
raise httpx.ConnectError("down")
client = httpx.AsyncClient(transport=httpx.MockTransport(boom))
sink = make_relay_sink("http://orch:8000", "sess-1", client)
await sink(StreamChunk(kind="text", text="x")) # must not raise
await client.aclose()
@pytest.mark.asyncio
async def test_receiver_enqueues_turn_and_validates() -> None:
queue: asyncio.Queue[str | None] = asyncio.Queue()
app = build_receiver(queue)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
ok = await client.post("/turn", json={"text": "build a thing"})
assert ok.status_code == HTTPStatus.OK
assert ok.json() == {"queued": True}
assert queue.get_nowait() == "build a thing"
bad = await client.post("/turn", json={"text": ""})
assert bad.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
health = await client.get("/health")
assert health.json() == {"status": "ok"}
+76 -16
View File
@@ -10,11 +10,12 @@ from uuid import uuid4
import pytest
from pydantic import ValidationError as PydanticValidationError
from roboco.api.schemas.prompter import (
CellWork,
ChatMessage,
PrompterChatRequest,
PrompterDraftTask,
PrompterMessageRequest,
PrompterSessionCreateRequest,
PrompterTurnResponse,
TaskConfirmRequest,
)
@@ -40,21 +41,6 @@ def test_chat_message_empty_content() -> None:
ChatMessage(role="user", content="")
# =============================================================================
# PrompterSessionCreateRequest
# =============================================================================
def test_session_create_request_defaults() -> None:
req = PrompterSessionCreateRequest()
assert req.context == {}
def test_session_create_request_with_context() -> None:
req = PrompterSessionCreateRequest(context={"team": "backend"})
assert req.context == {"team": "backend"}
# =============================================================================
# PrompterMessageRequest
# =============================================================================
@@ -196,6 +182,80 @@ def test_draft_task_priority_bounds() -> None:
)
# =============================================================================
# Structured spec fields
# =============================================================================
def test_cell_work_valid() -> None:
cw = CellWork(team="backend", summary="Build the endpoint", items=["Route", "Test"])
assert cw.team.value == "backend"
assert cw.items == ["Route", "Test"]
def test_cell_work_requires_summary() -> None:
with pytest.raises(PydanticValidationError):
CellWork(team="backend", summary="")
def test_draft_task_structured_fields_default_empty() -> None:
draft = PrompterDraftTask(
title="Add login page",
description="Implement a secure login page with email and password",
acceptance_criteria=["User can log in"],
team="frontend",
task_type="code",
nature="technical",
estimated_complexity="medium",
)
assert draft.objective is None
assert draft.what_this_builds == []
assert draft.the_work == []
assert draft.notes == []
def test_draft_task_with_structured_fields() -> None:
draft = PrompterDraftTask(
title="Ship the Prompter",
description="A board-led feature spanning three cells, fully wired.",
acceptance_criteria=["It works end to end"],
team="backend",
task_type="code",
nature="technical",
estimated_complexity="high",
objective="Let humans chat a task into existence.",
what_this_builds=["A /prompter page", "A chat endpoint"],
the_work=[
CellWork(team="backend", summary="Chat endpoint", items=["Route"]),
CellWork(team="frontend", summary="Chat UI", items=["Page"]),
],
notes=["Reuse the LLM service"],
)
assert [w.team.value for w in draft.the_work] == ["backend", "frontend"]
def test_confirm_request_carries_edited_draft() -> None:
draft = PrompterDraftTask(
title="Edited title",
description="An edited description that clears the minimum length.",
acceptance_criteria=["Done"],
team="frontend",
task_type="code",
nature="technical",
estimated_complexity="low",
)
req = TaskConfirmRequest(project_id=uuid4(), draft=draft)
assert req.draft is not None
assert req.draft.title == "Edited title"
def test_turn_response_shape() -> None:
resp = PrompterTurnResponse(messages=[], draft_ready=True, scale="multi")
assert resp.draft_ready is True
assert resp.scale == "multi"
assert resp.messages == []
# =============================================================================
# PrompterChatRequest (legacy)
# =============================================================================
+66
View File
@@ -0,0 +1,66 @@
"""``_fetch_budget_status`` — reading an agent's SDK budget endpoint.
Extracted from the budget kill-switch sweep so the swallow of an unreachable
SDK is observable (logged) rather than a silent ``try/except/continue``. These
tests pin the contract: a dict on 200-JSON, ``None`` on every benign failure.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import httpx
import pytest
from roboco.runtime.orchestrator import AgentOrchestrator
if TYPE_CHECKING:
from collections.abc import Callable
_URL = "http://roboco-agent-be-dev-1:9000/budget/status"
def _client(handler: Callable[[httpx.Request], httpx.Response]) -> httpx.AsyncClient:
return httpx.AsyncClient(transport=httpx.MockTransport(handler))
@pytest.mark.asyncio
async def test_returns_dict_on_200_json() -> None:
client = _client(lambda _r: httpx.Response(200, json={"halt": True, "total": 99}))
data = await AgentOrchestrator._fetch_budget_status(client, _URL, "be-dev-1")
assert data == {"halt": True, "total": 99}
await client.aclose()
@pytest.mark.asyncio
async def test_returns_none_when_unreachable() -> None:
def _boom(_r: httpx.Request) -> httpx.Response:
raise httpx.ConnectError("no route to host")
client = _client(_boom)
data = await AgentOrchestrator._fetch_budget_status(client, _URL, "be-dev-1")
assert data is None # benign: container not up yet / gone
await client.aclose()
@pytest.mark.asyncio
async def test_returns_none_on_non_200() -> None:
client = _client(lambda _r: httpx.Response(503))
data = await AgentOrchestrator._fetch_budget_status(client, _URL, "be-dev-1")
assert data is None
await client.aclose()
@pytest.mark.asyncio
async def test_returns_none_on_non_json_body() -> None:
client = _client(lambda _r: httpx.Response(200, text="not json"))
data = await AgentOrchestrator._fetch_budget_status(client, _URL, "be-dev-1")
assert data is None
await client.aclose()
@pytest.mark.asyncio
async def test_returns_none_when_json_is_not_an_object() -> None:
client = _client(lambda _r: httpx.Response(200, json=[1, 2, 3]))
data = await AgentOrchestrator._fetch_budget_status(client, _URL, "be-dev-1")
assert data is None # a list is not a status object
await client.aclose()
+419
View File
@@ -0,0 +1,419 @@
"""The persistent intake (prompter) live-session spawn/reap path.
The intake agent is not task-driven: ``spawn_intake_session`` launches a
long-lived Agent-SDK driver container (image ENTRYPOINT, NOT ``claude -p``),
clones the chat scope's repo(s), and registers the live relay session. These
tests cover the docker-command construction, scope resolution, and the
spawn/reap orchestration with docker + clone mocked (no daemon, no NAS).
"""
from __future__ import annotations
import asyncio
from pathlib import Path
from types import SimpleNamespace
from typing import Any
from unittest.mock import patch
from uuid import UUID
import pytest
from roboco.runtime.orchestrator import (
INTAKE_AGENT_ID,
AgentInstance,
AgentOrchestrator,
_IntakeRunSpec,
)
from roboco.services import prompter_live
def _make_minimal_orchestrator() -> AgentOrchestrator:
"""AgentOrchestrator with constructor I/O skipped; _instances ready."""
with patch.object(AgentOrchestrator, "__init__", return_value=None):
orch = AgentOrchestrator.__new__(AgentOrchestrator)
orch._instances = {}
orch._bg_tasks = set()
return orch
def _spec(**overrides: Any) -> _IntakeRunSpec:
base: dict[str, Any] = {
"container_name": "roboco-agent-intake-1",
"image": "roboco-agent-prompter",
"hosts": {
"claude": "/home/runner/.claude",
"prompt": "/data/prompts-generated/intake-1-prompt.md",
"workspaces": "/data/workspaces",
},
"session_id": "sess-abc",
"cwd": "/data/workspaces/roboco/board/intake-1",
"cli_model": "claude-opus-4-6",
"api_url": "http://roboco-orchestrator:8000",
"provider_base_url": None,
"provider_auth_token": None,
}
base.update(overrides)
return _IntakeRunSpec(**base)
@pytest.fixture(autouse=True)
def _fresh_registry() -> Any:
"""Isolate the process-wide live registry per test."""
prev = prompter_live._RegistryHolder.instance
prompter_live._RegistryHolder.instance = prompter_live.PrompterLiveRegistry()
yield
prompter_live._RegistryHolder.instance = prev
# ---------------------------------------------------------------------------
# _build_intake_run_cmd — the pure docker-argv builder.
# ---------------------------------------------------------------------------
class TestBuildIntakeRunCmd:
def test_image_is_last_and_no_claude_cli_args(self) -> None:
cmd = AgentOrchestrator._build_intake_run_cmd(_spec())
assert cmd[-1] == "roboco-agent-prompter"
# The image ENTRYPOINT is the driver — none of the claude CLI flags
# the task-driven path appends may appear here.
for flag in (
"-p",
"--model",
"--system-prompt-file",
"--mcp-config",
"--tools",
):
assert flag not in cmd, f"{flag} must not be in the intake run cmd"
def test_no_workdir_settings_or_manifest_mounts(self) -> None:
cmd = AgentOrchestrator._build_intake_run_cmd(_spec())
joined = " ".join(cmd)
assert "-w" not in cmd # driver sets cwd via ROBOCO_WORKSPACE/the SDK
assert "settings.json" not in joined # no hook mount (driver owns 9000)
assert "mcp-config.json" not in joined # MCP-free live agent
assert "tool-manifest.json" not in joined
def test_env_carries_session_workspace_and_api(self) -> None:
cmd = AgentOrchestrator._build_intake_run_cmd(_spec())
assert "ROBOCO_PROMPTER_SESSION_ID=sess-abc" in cmd
assert "ROBOCO_WORKSPACE=/data/workspaces/roboco/board/intake-1" in cmd
assert "ROBOCO_API_URL=http://roboco-orchestrator:8000" in cmd
assert "ROBOCO_AGENT_ID=intake-1" in cmd
assert "CLAUDE_CODE_SUBAGENT_MODEL=claude-opus-4-6" in cmd
def test_mounts_prompt_and_workspaces(self) -> None:
cmd = AgentOrchestrator._build_intake_run_cmd(_spec())
assert (
"/data/prompts-generated/intake-1-prompt.md:/app/system-prompt.md:ro" in cmd
)
assert "/data/workspaces:/data/workspaces" in cmd
def test_anthropic_default_omits_provider_env(self) -> None:
cmd = AgentOrchestrator._build_intake_run_cmd(_spec())
joined = " ".join(cmd)
assert "ANTHROPIC_BASE_URL" not in joined
assert "ANTHROPIC_AUTH_TOKEN" not in joined
def test_non_anthropic_injects_provider_env(self) -> None:
cmd = AgentOrchestrator._build_intake_run_cmd(
_spec(provider_base_url="http://ollama:11434/v1", provider_auth_token="tok")
)
assert "ANTHROPIC_BASE_URL=http://ollama:11434/v1" in cmd
assert "ANTHROPIC_AUTH_TOKEN=tok" in cmd
# ---------------------------------------------------------------------------
# _intake_scope_slugs — project XOR product resolution.
# ---------------------------------------------------------------------------
class TestIntakeScopeSlugs:
@pytest.mark.asyncio
async def test_project_scope_returns_single_slug(self) -> None:
slugs = await AgentOrchestrator._intake_scope_slugs(
db=object(), project_slug="roboco", product_id=None
)
assert slugs == ["roboco"]
@pytest.mark.asyncio
async def test_product_scope_resolves_distinct_projects_in_order(self) -> None:
# distinct_project_ids returns UUIDs in deterministic team order; the
# primary (cwd) is the first, so order must be preserved (not sorted).
ids = [
"11111111-1111-1111-1111-111111111111",
"22222222-2222-2222-2222-222222222222",
]
class _FakeProduct:
def __init__(self, _db: Any) -> None: ...
async def distinct_project_ids(self, _pid: Any) -> list[Any]:
return [UUID(i) for i in ids]
class _FakeProjectSvc:
async def get(self, pid: Any) -> Any:
return SimpleNamespace(slug=f"proj-{str(pid)[0]}")
with (
patch("roboco.services.product.ProductService", _FakeProduct),
patch(
"roboco.services.project.get_project_service",
lambda _db: _FakeProjectSvc(),
),
):
slugs = await AgentOrchestrator._intake_scope_slugs(
db=object(),
project_slug=None,
product_id="33333333-3333-3333-3333-333333333333",
)
assert slugs == ["proj-1", "proj-2"]
@pytest.mark.asyncio
async def test_product_with_no_projects_raises(self) -> None:
class _FakeProduct:
def __init__(self, _db: Any) -> None: ...
async def distinct_project_ids(self, _pid: Any) -> list[Any]:
return []
with (
patch("roboco.services.product.ProductService", _FakeProduct),
patch("roboco.services.project.get_project_service", lambda _db: object()),
pytest.raises(ValueError, match="no projects"),
):
await AgentOrchestrator._intake_scope_slugs(
db=object(),
project_slug=None,
product_id="33333333-3333-3333-3333-333333333333",
)
# ---------------------------------------------------------------------------
# spawn_intake_session / reap_intake_session — orchestration (docker mocked).
# ---------------------------------------------------------------------------
def _fake_route() -> SimpleNamespace:
return SimpleNamespace(
provider_type=SimpleNamespace(value="anthropic"),
model_name="opus",
base_url=None,
auth_token=None,
)
def _wire_spawn_mocks(
monkeypatch: pytest.MonkeyPatch,
orch: AgentOrchestrator,
run_calls: list[list[str]],
) -> None:
"""Patch every external boundary spawn_intake_session touches."""
async def _clone(_p: Any, _pr: Any) -> tuple[str, list[str]]:
return "/data/workspaces/roboco/board/intake-1", [
"/data/workspaces/roboco/board/intake-1"
]
async def _route(_aid: str) -> Any:
return _fake_route()
async def _noop(*_a: Any, **_k: Any) -> None:
return None
async def _run(cmd: list[str]) -> str:
run_calls.append(cmd)
return "containerid0123456789"
monkeypatch.setattr(orch, "_clone_intake_scope", _clone)
monkeypatch.setattr(orch, "_resolve_agent_route", _route)
monkeypatch.setattr(orch, "_ensure_agent_image", _noop)
monkeypatch.setattr(orch, "_remove_container", _noop)
monkeypatch.setattr(orch, "_run_container_cmd", _run)
monkeypatch.setattr(orch, "_fire_audit", lambda **_k: None)
monkeypatch.setattr(
orch,
"_generate_composed_prompt",
lambda _aid: Path("/tmp/intake-1-prompt.md"),
)
monkeypatch.setattr(
orch,
"_resolve_intake_host_paths",
lambda: {
"claude": "/home/runner/.claude",
"prompt": "/data/prompts-generated/intake-1-prompt.md",
"workspaces": "/data/workspaces",
},
)
class TestSpawnIntakeSession:
@pytest.mark.asyncio
async def test_spawn_registers_session_and_instance(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
orch = _make_minimal_orchestrator()
run_calls: list[list[str]] = []
_wire_spawn_mocks(monkeypatch, orch, run_calls)
instance = await orch.spawn_intake_session("sess-1", project_slug="roboco")
# Live relay session opened for the container.
session = prompter_live.get_live_registry().get("sess-1")
assert session is not None
assert session.agent_id == INTAKE_AGENT_ID
# Orchestrator instance tracked and marked active.
assert orch._instances[INTAKE_AGENT_ID] is instance
assert instance.container_id == "containerid0123456789"
# The cloned cwd reached the docker cmd.
assert "ROBOCO_WORKSPACE=/data/workspaces/roboco/board/intake-1" in run_calls[0]
@pytest.mark.asyncio
async def test_scope_must_be_exactly_one(self) -> None:
orch = _make_minimal_orchestrator()
with pytest.raises(ValueError, match="exactly one"):
await orch.spawn_intake_session("s", project_slug="roboco", product_id="p")
with pytest.raises(ValueError, match="exactly one"):
await orch.spawn_intake_session("s")
@pytest.mark.asyncio
async def test_spawn_reaps_prior_session_first(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
orch = _make_minimal_orchestrator()
run_calls: list[list[str]] = []
_wire_spawn_mocks(monkeypatch, orch, run_calls)
stopped: list[str] = []
async def _stop(aid: str, **_kw: Any) -> None:
stopped.append(aid)
monkeypatch.setattr(orch, "stop_agent", _stop)
# A prior live container already registered for this agent.
orch._instances[INTAKE_AGENT_ID] = AgentInstance(agent_id=INTAKE_AGENT_ID)
await orch.spawn_intake_session("sess-2", project_slug="roboco")
assert stopped == [INTAKE_AGENT_ID] # the old one was reaped first
@pytest.mark.asyncio
async def test_initial_message_is_scheduled(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
orch = _make_minimal_orchestrator()
run_calls: list[list[str]] = []
_wire_spawn_mocks(monkeypatch, orch, run_calls)
scheduled: list[tuple[str, str]] = []
monkeypatch.setattr(
orch,
"_schedule_intake_first_message",
lambda sid, text: scheduled.append((sid, text)),
)
await orch.spawn_intake_session(
"sess-3", project_slug="roboco", initial_message="build X"
)
assert scheduled == [("sess-3", "build X")]
class TestStartIntakeSession:
"""Non-blocking start: relay opens synchronously, spawn runs in the background."""
@pytest.mark.asyncio
async def test_opens_relay_now_and_schedules_spawn(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
orch = _make_minimal_orchestrator()
spawned: list[str] = []
async def _spawn(session_id: str, **_kw: Any) -> Any:
spawned.append(session_id)
return AgentInstance(agent_id=INTAKE_AGENT_ID)
monkeypatch.setattr(orch, "_spawn_intake_container", _spawn)
await orch.start_intake_session("sess-A", project_slug="roboco")
# Relay is open the instant start returns — the SSE stream can connect
# before the (slow) container spawn finishes.
assert prompter_live.get_live_registry().get("sess-A") is not None
await asyncio.sleep(0) # let the scheduled bg spawn run
assert spawned == ["sess-A"]
@pytest.mark.asyncio
async def test_rejects_bad_scope(self) -> None:
orch = _make_minimal_orchestrator()
with pytest.raises(ValueError, match="exactly one"):
await orch.start_intake_session("s", project_slug="r", product_id="p")
class TestSpawnGuarded:
"""A background spawn failure surfaces on the relay instead of dying silently."""
@pytest.mark.asyncio
async def test_failure_pushes_error_and_closes(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
orch = _make_minimal_orchestrator()
registry = prompter_live.get_live_registry()
registry.open("sess-B", INTAKE_AGENT_ID)
pushed: list[tuple[str, dict[str, Any]]] = []
closed: list[str] = []
async def _boom(_session_id: str, **_kw: Any) -> Any:
raise RuntimeError("clone exploded")
def _push(sid: str, ev: dict[str, Any]) -> bool:
pushed.append((sid, ev))
return True
monkeypatch.setattr(orch, "_spawn_intake_container", _boom)
monkeypatch.setattr(registry, "push", _push)
monkeypatch.setattr(registry, "close", closed.append)
await orch._spawn_intake_container_guarded(
"sess-B", project_slug="roboco", product_id=None, initial_message=None
)
assert len(pushed) == 1
assert pushed[0][1]["kind"] == "error"
assert "clone exploded" in pushed[0][1]["text"]
assert closed == ["sess-B"]
class TestReapIntakeSession:
@pytest.mark.asyncio
async def test_reap_closes_session_and_stops_container(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
orch = _make_minimal_orchestrator()
stopped: list[str] = []
async def _stop(aid: str, **_kw: Any) -> None:
stopped.append(aid)
monkeypatch.setattr(orch, "stop_agent", _stop)
registry = prompter_live.get_live_registry()
registry.open("sess-x", INTAKE_AGENT_ID)
await orch.reap_intake_session("sess-x")
assert registry.get("sess-x") is None # relay session closed
assert stopped == [INTAKE_AGENT_ID]
class TestDeliverWhenReady:
@pytest.mark.asyncio
async def test_retries_until_receiver_is_up(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
orch = _make_minimal_orchestrator()
registry = prompter_live.get_live_registry()
succeed_on = 2 # fails once, then succeeds
attempts = {"n": 0}
async def _deliver(_sid: str, _text: str) -> bool:
attempts["n"] += 1
return attempts["n"] >= succeed_on
monkeypatch.setattr(registry, "deliver", _deliver)
await orch._deliver_when_ready("sess-y", "hi", attempts=5, delay=0)
assert attempts["n"] == succeed_on # stopped as soon as delivery succeeded
+327 -31
View File
@@ -9,19 +9,35 @@ from __future__ import annotations
import json
from typing import Any
from unittest.mock import AsyncMock, patch
from uuid import uuid4
from uuid import UUID, uuid4
import pytest
from roboco.db.tables import AgentTable
from roboco.models.base import AgentRole, AgentStatus
from roboco.db.tables import (
AgentTable,
ProductTable,
ProjectTable,
TaskTable,
)
from roboco.models.base import (
AgentRole,
AgentStatus,
Complexity,
TaskNature,
TaskStatus,
TaskType,
Team,
)
from roboco.seeds.initial_data import AGENT_UUIDS
from roboco.services.base import NotFoundError, ServiceError, ValidationError
from roboco.services.prompter import (
PrompterService,
_build_chat_prompt,
_build_draft_prompt,
_build_reasoning,
_detect_draft_ready,
compose_description,
derive_scale,
get_prompter_service,
parse_readiness,
)
# =============================================================================
@@ -29,27 +45,168 @@ from roboco.services.prompter import (
# =============================================================================
def test_detect_draft_ready_signals() -> None:
signals = [
"I have enough information to proceed",
"Ready to generate a draft now.",
"ready to draft the task",
"i can now draft this.",
"draft_ready=true",
"The task is draft ready",
]
for text in signals:
assert _detect_draft_ready(text), f"Expected True for: {text!r}"
def test_parse_readiness_extracts_and_strips_tag() -> None:
content = (
"Here is my question about scope.\n\n"
'```roboco-meta\n{"covered": ["objective", "scope"], '
'"ready": true, "scale": "multi"}\n```'
)
clean, tag = parse_readiness(content)
assert clean == "Here is my question about scope."
assert tag is not None
assert tag.ready is True
assert tag.scale == "multi"
assert tag.covered == ["objective", "scope"]
# The control block must not leak into the user-visible text.
assert "roboco-meta" not in clean
def test_detect_draft_ready_negative() -> None:
not_signals = [
"Tell me more about the feature.",
"Could you clarify the acceptance criteria?",
"Let's continue the conversation.",
]
for text in not_signals:
assert not _detect_draft_ready(text), f"Expected False for: {text!r}"
def test_parse_readiness_absent_block_is_not_ready() -> None:
clean, tag = parse_readiness("Just a plain reply, no control block.")
assert clean == "Just a plain reply, no control block."
assert tag is None
def test_parse_readiness_malformed_json_is_graceful() -> None:
content = "Reply text.\n```roboco-meta\n{not valid json]\n```"
clean, tag = parse_readiness(content)
assert "roboco-meta" not in clean
assert clean == "Reply text."
assert tag is None
def test_parse_readiness_uses_last_block() -> None:
content = (
'```roboco-meta\n{"ready": false, "scale": "single"}\n```\n'
"Final answer.\n"
'```roboco-meta\n{"ready": true, "scale": "multi"}\n```'
)
clean, tag = parse_readiness(content)
assert tag is not None
assert tag.ready is True
assert tag.scale == "multi"
assert "roboco-meta" not in clean
def test_derive_scale_single_vs_multi() -> None:
assert derive_scale([{"team": "backend"}]) == "single"
assert derive_scale([{"team": "backend"}, {"team": "frontend"}]) == "multi"
# Non-cell teams (e.g. main_pm) do not count toward cell breadth.
assert derive_scale([{"team": "backend"}, {"team": "main_pm"}]) == "single"
assert derive_scale([]) == "single"
def test_compose_description_single_cell_markdown() -> None:
draft = {
"objective": "Let humans track token usage.",
"what_this_builds": ["A usage panel on the Metrics page"],
"the_work": [
{
"team": "frontend",
"summary": "Render the usage panel",
"items": ["Add the chart", "Wire the API"],
}
],
"notes": ["Reuse the existing Metrics layout"],
"acceptance_criteria": ["Panel shows totals", "Panel filters by range"],
}
md = compose_description(draft)
assert "## Objective" in md
assert "## What This Builds" in md
assert "## The Work" in md
assert "**Frontend** — Render the usage panel" in md
assert "## Notes" in md
assert "## Success Criteria" in md
assert "- Panel shows totals" in md
# Single-cell tasks get no board-led lead line.
assert "Board-led" not in md
def test_compose_description_multi_cell_has_board_led_lead() -> None:
draft = {
"objective": "Ship the Prompter.",
"the_work": [
{"team": "backend", "summary": "Chat endpoint", "items": []},
{"team": "frontend", "summary": "Chat UI", "items": []},
{"team": "ux_ui", "summary": "Interaction design", "items": []},
],
"acceptance_criteria": ["It works end to end"],
}
md = compose_description(draft)
assert "Board-led" in md
assert "**Backend**" in md
assert "**UX/UI**" in md
def test_compose_description_falls_back_to_provided_description() -> None:
# Sparse structured fields → fall back to a model-provided description.
draft = {"description": "A perfectly adequate fallback description here."}
md = compose_description(draft)
assert md == "A perfectly adequate fallback description here."
def test_lead_cell_team_prefers_the_work_cell() -> None:
draft = {"the_work": [{"team": "frontend"}], "team": "backend"}
assert PrompterService._lead_cell_team(draft, default=Team.BACKEND) is Team.FRONTEND
# Empty the_work falls back to the provided default.
assert PrompterService._lead_cell_team({}, default=Team.BACKEND) is Team.BACKEND
def test_lead_cell_team_skips_invalid_cell_names() -> None:
# An off-enum cell name is skipped, not raised on; falls through to a valid one.
draft = {"the_work": [{"team": "nonsense"}, {"team": "frontend"}]}
assert PrompterService._lead_cell_team(draft, default=Team.BACKEND) is Team.FRONTEND
def test_coerce_draft_enums_defaults_invalid_values() -> None:
# Regression: the LLM emits off-enum values (e.g. task_type="feature"). The
# confirm must coerce to defaults, never raise — a bad enum guess must not
# 400 the launch and force the agent to self-correct in-chat.
draft = {
"team": "backend",
"task_type": "feature", # not a valid TaskType
"nature": "bogus", # not a valid TaskNature
"estimated_complexity": "enormous", # not a valid Complexity
}
team, task_type, nature, complexity = PrompterService._coerce_draft_enums(draft)
assert team is Team.BACKEND
assert task_type is TaskType.CODE
assert nature is TaskNature.TECHNICAL
assert complexity is Complexity.MEDIUM
def test_coerce_priority_maps_words_clamps_and_defaults() -> None:
# Regression: priority is the one non-enum field the agent guesses, and it
# guesses a word ("high") as often as a number — int("high") used to 500.
# word/number -> expected priority int (0=urgent .. 3=low).
cases: dict[object, int] = {
"urgent": 0,
"high": 1,
"medium": 2,
"low": 3,
1: 1,
"3": 3,
99: 3, # clamped into range
"nonsense": 2, # unrecognized -> default medium
None: 2, # missing -> default medium
}
for value, expected in cases.items():
assert PrompterService._coerce_priority(value) == expected
def test_coerce_draft_enums_keeps_valid_and_derives_missing_team() -> None:
# Valid values pass through; a missing team is derived from the_work.
draft = {
"task_type": "documentation",
"nature": "technical",
"estimated_complexity": "medium",
"the_work": [{"team": "frontend"}],
}
team, task_type, nature, complexity = PrompterService._coerce_draft_enums(draft)
assert team is Team.FRONTEND
assert task_type is TaskType.DOCUMENTATION
assert nature is TaskNature.TECHNICAL
assert complexity is Complexity.MEDIUM
def test_build_chat_prompt_basic() -> None:
@@ -131,17 +288,26 @@ async def test_chat_success_with_mock_llm() -> None:
async def test_chat_draft_ready_signal() -> None:
service = get_prompter_service()
reply = (
"Got it — I have what I need.\n\n"
'```roboco-meta\n{"covered": ["objective", "scope", "surface", '
'"acceptance"], "ready": true, "scale": "single"}\n```'
)
with patch.object(
service,
"_create_message",
new_callable=AsyncMock,
return_value="I have enough information. Ready to draft.",
return_value=reply,
):
result = await service.chat(
messages=[{"role": "user", "content": "I need a feature"}]
)
assert result["draft_ready"] is True
assert result["scale"] == "single"
# The control block is stripped from the user-visible reply.
assert "roboco-meta" not in result["message"]
assert result["message"] == "Got it — I have what I need."
@pytest.mark.asyncio
@@ -245,8 +411,9 @@ async def test_create_session_db(db_session: Any) -> None:
"""create_session persists a PrompterSessionTable row."""
service = get_prompter_service(db=db_session)
agent_id = uuid4()
agent = AgentTable(
id=uuid4(),
id=agent_id,
name="TestAgent",
slug=f"test-{uuid4().hex[:8]}",
role=AgentRole.DEVELOPER,
@@ -261,10 +428,10 @@ async def test_create_session_db(db_session: Any) -> None:
db_session.add(agent)
await db_session.flush()
session = await service.create_session(agent_id=agent.id) # type: ignore[arg-type]
session = await service.create_session(agent_id=agent_id)
assert session.id is not None
assert session.status == "active"
assert session.agent_id == agent.id
assert session.agent_id == agent_id
@pytest.mark.asyncio
@@ -280,8 +447,9 @@ async def test_get_draft_empty_session_raises(db_session: Any) -> None:
"""get_or_generate_draft raises ValidationError if no messages exist."""
service = get_prompter_service(db=db_session)
agent_id = uuid4()
agent = AgentTable(
id=uuid4(),
id=agent_id,
name="TestAgent",
slug=f"test-{uuid4().hex[:8]}",
role=AgentRole.DEVELOPER,
@@ -296,10 +464,138 @@ async def test_get_draft_empty_session_raises(db_session: Any) -> None:
db_session.add(agent)
await db_session.flush()
session = await service.create_session(agent_id=agent.id) # type: ignore[arg-type]
session = await service.create_session(agent_id=agent_id)
with pytest.raises(ValidationError, match="empty conversation"):
await service.get_or_generate_draft(
session_id=session.id, # type: ignore[arg-type]
agent_id=agent.id, # type: ignore[arg-type]
session_id=UUID(str(session.id)),
agent_id=agent_id,
)
async def _seed_project_and_ceo(db_session: Any) -> tuple[UUID, UUID]:
"""Seed a system agent + project + CEO; return (project_id, ceo_id).
Returns plain ``UUID``s (not the ORM rows) so callers pass real uuids to the
service no casting the ORM ``.id`` column type at the call site.
"""
system_id, project_id, ceo_id = uuid4(), uuid4(), uuid4()
system = AgentTable(
id=system_id,
name="System",
slug=f"system-{uuid4().hex[:8]}",
role=AgentRole.SYSTEM,
team=None,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="system",
capabilities=[],
permissions={},
metrics={},
)
db_session.add(system)
await db_session.flush()
project = ProjectTable(
id=project_id,
name="Intake Test Project",
slug=f"intake-{uuid4().hex[:8]}",
git_url="https://github.com/example/intake.git",
default_branch="main",
protected_branches=["main"],
assigned_cell=Team.BACKEND,
created_by=system_id,
is_active=True,
)
ceo = AgentTable(
id=ceo_id,
name="CEO",
slug=f"ceo-{uuid4().hex[:8]}",
role=AgentRole.CEO,
team=None,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="ceo",
capabilities=[],
permissions={},
metrics={},
)
db_session.add_all([project, ceo])
await db_session.flush()
return project_id, ceo_id
@pytest.mark.asyncio
async def test_confirm_live_draft_board_route_assigns_po(db_session: Any) -> None:
""" "Board review & Start" (default route) → PENDING, assigned to the Product
Owner so the orchestrator fires the PO + HoM review."""
project_id, ceo_id = await _seed_project_and_ceo(db_session)
service = get_prompter_service(db=db_session)
draft = {
"title": "Add token metrics",
"objective": "See token usage at a glance.",
"acceptance_criteria": ["Dashboard shows total tokens"],
"team": "backend",
"the_work": [
{"team": "backend", "summary": "instrument", "items": ["count tokens"]}
],
}
task_id = await service.confirm_live_draft(draft, ceo_id, project_id=project_id)
row = await db_session.get(TaskTable, task_id)
assert row is not None
assert row.status == TaskStatus.PENDING # "& Start" — started now
assert row.assigned_to == UUID(AGENT_UUIDS["product-owner"]) # board review
assert row.source == "prompter"
assert row.confirmed_by_human is True
assert row.team == Team.BACKEND # lead cell from the_work
assert row.created_by == ceo_id
assert row.nature is not None and row.task_type is not None
@pytest.mark.asyncio
async def test_confirm_live_draft_main_pm_route_assigns_main_pm(
db_session: Any,
) -> None:
""" "Approve & Start" (route="main_pm") → PENDING, assigned to the Main PM."""
project_id, ceo_id = await _seed_project_and_ceo(db_session)
service = get_prompter_service(db=db_session)
draft = {
"title": "Quick fix",
"acceptance_criteria": ["done"],
"team": "backend",
}
task_id = await service.confirm_live_draft(
draft, ceo_id, project_id=project_id, route="main_pm"
)
row = await db_session.get(TaskTable, task_id)
assert row.status == TaskStatus.PENDING
assert row.assigned_to == UUID(AGENT_UUIDS["main-pm"])
@pytest.mark.asyncio
async def test_confirm_live_draft_product_routes_to_main_pm(db_session: Any) -> None:
"""A product-scoped live draft is a board-led coordination root (Main PM)."""
_project_id, ceo_id = await _seed_project_and_ceo(db_session)
product_id = uuid4()
product = ProductTable(
id=product_id,
name="Intake Product",
slug=f"prod-{uuid4().hex[:8]}",
description="x",
created_by=ceo_id,
)
db_session.add(product)
await db_session.flush()
service = get_prompter_service(db=db_session)
draft = {
"title": "Board-led feature",
"acceptance_criteria": ["works end to end"],
"team": "backend",
}
task_id = await service.confirm_live_draft(draft, ceo_id, product_id=product_id)
row = await db_session.get(TaskTable, task_id)
assert row.team == Team.MAIN_PM
assert row.product_id == product_id
assert row.project_id is None
+117
View File
@@ -0,0 +1,117 @@
"""Unit tests for the live intake-session relay (orchestrator side)."""
from __future__ import annotations
import asyncio
import json
import httpx
import pytest
from roboco.services.prompter_live import (
PrompterLiveRegistry,
get_live_registry,
)
def test_open_get_close() -> None:
reg = PrompterLiveRegistry()
session = reg.open("s1", "intake-1")
assert session.agent_id == "intake-1"
assert reg.get("s1") is session
reg.close("s1")
assert reg.get("s1") is None
def test_open_is_idempotent_for_a_live_session() -> None:
"""Re-opening a live session returns the SAME object (same queue).
Regression: a second open() that swapped in a fresh queue orphaned the SSE
stream the panel had already captured the first queue, so the agent's
replies (pushed to the new queue) never reached the browser.
"""
reg = PrompterLiveRegistry()
first = reg.open("s1", "intake-1")
first.queue.put_nowait({"event": "text"}) # something already queued
second = reg.open("s1", "intake-1")
assert second is first # not replaced
assert second.queue is first.queue # same queue → stream not orphaned
# After close, a re-open starts fresh (no stale queue carried over).
reg.close("s1")
third = reg.open("s1", "intake-1")
assert third is not first
assert third.queue.empty()
def test_push_to_unknown_or_closed_returns_false() -> None:
reg = PrompterLiveRegistry()
assert reg.push("nope", {"event": "text"}) is False
reg.open("s1", "intake-1")
assert reg.push("s1", {"event": "text"}) is True
reg.close("s1")
assert reg.push("s1", {"event": "text"}) is False
@pytest.mark.asyncio
async def test_stream_yields_queued_events_then_ends_on_close() -> None:
reg = PrompterLiveRegistry()
reg.open("s1", "intake-1")
async def collect() -> list[dict]:
return [ev async for ev in reg.stream("s1")]
task = asyncio.create_task(collect())
await asyncio.sleep(0) # let the stream capture the session + block on get()
reg.push("s1", {"event": "text", "data": "hel"})
reg.push("s1", {"event": "turn_end", "data": "{}"})
reg.close("s1") # sentinel ends the stream
result = await asyncio.wait_for(task, timeout=1.0)
assert result == [
{"event": "text", "data": "hel"},
{"event": "turn_end", "data": "{}"},
]
@pytest.mark.asyncio
async def test_stream_unknown_session_is_empty() -> None:
reg = PrompterLiveRegistry()
assert [ev async for ev in reg.stream("nope")] == []
@pytest.mark.asyncio
async def test_deliver_posts_to_the_container_receiver() -> None:
seen: dict[str, object] = {}
def handler(request: httpx.Request) -> httpx.Response:
seen["host"] = request.url.host
seen["path"] = request.url.path
seen["body"] = json.loads(request.content)
return httpx.Response(200, json={"ok": True})
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
reg = PrompterLiveRegistry(http_client=client)
reg.open("s1", "intake-1")
assert await reg.deliver("s1", "hello there") is True
assert seen["host"] == "roboco-agent-intake-1"
assert seen["path"] == "/turn"
assert seen["body"] == {"text": "hello there"}
await client.aclose()
@pytest.mark.asyncio
async def test_deliver_to_unknown_or_failing_returns_false() -> None:
def fail(_request: httpx.Request) -> httpx.Response:
return httpx.Response(500)
client = httpx.AsyncClient(transport=httpx.MockTransport(fail))
reg = PrompterLiveRegistry(http_client=client)
assert await reg.deliver("nope", "hi") is False # unknown session
reg.open("s1", "intake-1")
assert await reg.deliver("s1", "hi") is False # 500 from container
await client.aclose()
def test_registry_singleton() -> None:
assert get_live_registry() is get_live_registry()
+5 -3
View File
@@ -4,10 +4,11 @@ Operating the AI company after deployment.
## The Organization
19 AI agents organized as a company:
20 AI agents organized as a company:
```
CEO (You)
├── Intake (on-demand interviewer: chats only with you to draft a task)
└── Board
├── Product Owner
├── Head of Marketing
@@ -38,6 +39,7 @@ CEO (You)
| `product-owner` | Product Owner | Board |
| `head-marketing` | Head of Marketing | Board |
| `auditor` | Auditor | Board |
| `intake-1` | Intake (interviewer) | Board |
## Spawning Agents
@@ -206,7 +208,7 @@ docker compose down
### Start Small
Don't spawn all 19 agents at once. Start with:
Don't spawn the whole fleet at once. Start with:
1. `main-pm` alone - verify spawning works
2. Add `be-dev-1` - verify task claiming
3. Add `be-qa` - verify full workflow
@@ -236,7 +238,7 @@ docker logs -f roboco-agent-be-dev-1
Each agent container uses ~500MB-2GB RAM depending on context. With 128GB RAM:
- 3 agents: ~6GB
- 6 agents: ~12GB
- 19 agents: ~38GB
- 20 agents: ~40GB (the intake interviewer is on-demand — it only runs while you're drafting a task)
Monitor with:
```bash
Generated
+36 -15
View File
@@ -57,7 +57,7 @@ wheels = [
[[package]]
name = "anthropic"
version = "0.106.0"
version = "0.107.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
@@ -69,9 +69,9 @@ dependencies = [
{ name = "sniffio" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/bf/69/f1b6b5918d18ab47193bc7dd316bc208bc98decab5cf69b328d8a3e258dc/anthropic-0.106.0.tar.gz", hash = "sha256:f26e2645e31f66eff526b923f539b80b4b6eda1a918790cd77c0afe5e24a2203", size = 855469, upload-time = "2026-06-05T21:13:26.555Z" }
sdist = { url = "https://files.pythonhosted.org/packages/b1/f1/c6076a92e0bf6b0dfa126e213b3f9e8a510acd73567953210713aae6c256/anthropic-0.107.1.tar.gz", hash = "sha256:8e7169a6ab57fb806b778d9af018c867bad688144efec8969cdb4c5ccecd6670", size = 856312, upload-time = "2026-06-07T17:18:57.358Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/7a/8c64e9ba12b16d5d1c64e0fc9da1f32cbf6742f14fe2ad943f83a2ed8639/anthropic-0.106.0-py3-none-any.whl", hash = "sha256:d0e4a7448e54c3942833cee5b3de5f1b31289fd49999bfbcc2ec0c0acaddf75f", size = 838152, upload-time = "2026-06-05T21:13:28.404Z" },
{ url = "https://files.pythonhosted.org/packages/86/0e/71432f0777a263701955a23ebcc6650485c2753be9afbce2a6a8d72526e3/anthropic-0.107.1-py3-none-any.whl", hash = "sha256:b74338d08000ba105dfc8adae29af3713ece845a4bffec9986a20697e087c7b3", size = 838729, upload-time = "2026-06-07T17:18:58.729Z" },
]
[[package]]
@@ -431,15 +431,15 @@ wheels = [
[[package]]
name = "beautifulsoup4"
version = "4.14.3"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "soupsieve" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" }
sdist = { url = "https://files.pythonhosted.org/packages/43/65/318323f98dbee45d42dff61d8f047181bc6f2268a9068cfad035a46be5af/beautifulsoup4-4.15.0.tar.gz", hash = "sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7", size = 632571, upload-time = "2026-06-07T16:44:20.453Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" },
{ url = "https://files.pythonhosted.org/packages/88/c6/92fcd42f1ba33e1184263f25bfabf3d27c383410470f169e4b8163bf9c17/beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9", size = 109924, upload-time = "2026-06-07T16:44:21.566Z" },
]
[[package]]
@@ -665,6 +665,25 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" },
]
[[package]]
name = "claude-agent-sdk"
version = "0.2.94"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "mcp" },
{ name = "sniffio" },
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2a/91/f2b5025cffdb983afd09a0d13f3f9ec6443c41f619bf6084dbea9fb43dee/claude_agent_sdk-0.2.94.tar.gz", hash = "sha256:fc0036ea53fc1c576a8ae171b708976d20ea654b5c25e59528dd34d570cf0d98", size = 253646, upload-time = "2026-06-08T22:09:18.378Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/21/646aa4ee36ac31a6f34766e2ac543f6752fa62471ddfb261daa50e0de696/claude_agent_sdk-0.2.94-py3-none-macosx_11_0_arm64.whl", hash = "sha256:99fbc1395ba851b0e53d3ae530c9341dce2e5a29def656bbcb6eb8bbe7a9b26f", size = 65340270, upload-time = "2026-06-08T22:09:21.834Z" },
{ url = "https://files.pythonhosted.org/packages/de/24/5df70fdf3e9b3300e3bdd0352f277bbfa4dc3233e7e3bfb232677f834b47/claude_agent_sdk-0.2.94-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:f48d87fe3098052d0e6020227bd023e432fd6d61c1eccec67d65c9a15ca2e9ba", size = 67422547, upload-time = "2026-06-08T22:09:25.539Z" },
{ url = "https://files.pythonhosted.org/packages/35/57/3a197f3c0a5b5cde96d209a4f3d86adc06b3c0b2e8fbc227bad61ae0800a/claude_agent_sdk-0.2.94-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:cc9c54320c10bcd0dd62e6cabcda493fdf6129a79cd2c518dd04a3f6e43d0796", size = 74955511, upload-time = "2026-06-08T22:09:28.712Z" },
{ url = "https://files.pythonhosted.org/packages/cc/09/d3b113779c3c3286c593a49603fdc34ad335b92097ec802c6524dd33f0bc/claude_agent_sdk-0.2.94-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:0234ba5a04aca74c650c1557278e29223e96d66a92af3b3c4c7b8f557d86cbb4", size = 75130282, upload-time = "2026-06-08T22:09:32.413Z" },
{ url = "https://files.pythonhosted.org/packages/8a/72/fb4af71f4fe96b6f4bbe2a4c3d2eb486b7fb3780ec89f2549e2c6dd8d643/claude_agent_sdk-0.2.94-py3-none-win_amd64.whl", hash = "sha256:3040182790f5000a893b8a4a25bfd97c94308ce88b16c0c63176ae770f28eefc", size = 75767329, upload-time = "2026-06-08T22:09:36.314Z" },
]
[[package]]
name = "click"
version = "8.4.1"
@@ -887,7 +906,7 @@ wheels = [
[[package]]
name = "cyclonedx-python-lib"
version = "11.8.0"
version = "11.9.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "license-expression" },
@@ -896,9 +915,9 @@ dependencies = [
{ name = "sortedcontainers" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/83/45/5ce2515e092656ada3959897681a24a7299f13638d4805de3d1fd9f91342/cyclonedx_python_lib-11.8.0.tar.gz", hash = "sha256:a8935b06dfe53d80efd47b5f6a202eb678cea00f2930e94e3710f2c1510166c9", size = 1422887, upload-time = "2026-06-04T10:38:27.885Z" }
sdist = { url = "https://files.pythonhosted.org/packages/33/86/3508db3dade17e1f8cfa289b7dd3df7c2f0401d8cf7ee186042bdb3ab003/cyclonedx_python_lib-11.9.0.tar.gz", hash = "sha256:5d3b54834bbdfa2538b0e7eeda243f43c136d0a324d175fd8d6ec8685ee81f3c", size = 1424867, upload-time = "2026-06-08T07:32:26.945Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/57/af/333ad82adec8121dae568f7d094a3b08ba485340cf19fe10674f2db14774/cyclonedx_python_lib-11.8.0-py3-none-any.whl", hash = "sha256:a326b575acbb3aa1040986307a4ae282ff27f99ffcbdfdaaf8de10ee85e97f01", size = 523911, upload-time = "2026-06-04T10:38:26.248Z" },
{ url = "https://files.pythonhosted.org/packages/13/cd/8e671a62b18a946ea4923914030a68297cf3a50ac3bb169facc7b6c0d995/cyclonedx_python_lib-11.9.0-py3-none-any.whl", hash = "sha256:80620df4d11628458b7a17523b3f62be4663779babf51c82fe0ca7a32e3d0633", size = 524883, upload-time = "2026-06-08T07:32:25.094Z" },
]
[[package]]
@@ -4312,6 +4331,7 @@ dependencies = [
{ name = "alembic" },
{ name = "anthropic" },
{ name = "asyncpg" },
{ name = "claude-agent-sdk" },
{ name = "cryptography" },
{ name = "fastapi" },
{ name = "hiredis" },
@@ -4381,6 +4401,7 @@ requires-dist = [
{ name = "anthropic" },
{ name = "asyncpg" },
{ name = "bandit", marker = "extra == 'dev'" },
{ name = "claude-agent-sdk", specifier = ">=0.2.94" },
{ name = "cryptography" },
{ name = "deptry", marker = "extra == 'dev'" },
{ name = "factory-boy", marker = "extra == 'dev'" },
@@ -5226,14 +5247,14 @@ wheels = [
[[package]]
name = "structlog"
version = "25.5.0"
version = "26.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ef/52/9ba0f43b686e7f3ddfeaa78ac3af750292662284b3661e91ad5494f21dbc/structlog-25.5.0.tar.gz", hash = "sha256:098522a3bebed9153d4570c6d0288abf80a031dfdb2048d59a49e9dc2190fc98", size = 1460830, upload-time = "2025-10-27T08:28:23.028Z" }
sdist = { url = "https://files.pythonhosted.org/packages/5e/89/b4a0bcfdf4f71a3dea31379f095929613d7e4528a0996bca6aa964cd0dca/structlog-26.1.0.tar.gz", hash = "sha256:f63a716cbd1b1291cf7661de7794b455acfa4c43c5bcf1630e6ad5ddc1adb3b7", size = 1459881, upload-time = "2026-06-06T07:33:39.348Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a8/45/a132b9074aa18e799b891b91ad72133c98d8042c70f6240e4c5f9dabee2f/structlog-25.5.0-py3-none-any.whl", hash = "sha256:a8453e9b9e636ec59bd9e79bbd4a72f025981b3ba0f5837aebf48f02f37a7f9f", size = 72510, upload-time = "2025-10-27T08:28:21.535Z" },
{ url = "https://files.pythonhosted.org/packages/a9/18/489c97b834dfff9cf2fc2507cede4bcd4b11e67f84bc462acd1992496f86/structlog-26.1.0-py3-none-any.whl", hash = "sha256:e081a26d6c373e6d201eca24eede26d8ffab07f88f477822e679183428d3d91e", size = 73764, upload-time = "2026-06-06T07:33:38.046Z" },
]
[[package]]
@@ -5905,11 +5926,11 @@ wheels = [
[[package]]
name = "wcwidth"
version = "0.8.0"
version = "0.8.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/af/44/c833e6b746ffb654e9abacf7ad6c2480a9c8c42e9637c1ae849964fb4dde/wcwidth-0.8.0.tar.gz", hash = "sha256:68a882ff6d14e3d14e0cae590b96a0551be64ce4905408112a8254434a1bdf69", size = 1305357, upload-time = "2026-06-05T21:19:35.667Z" }
sdist = { url = "https://files.pythonhosted.org/packages/49/b4/51fe890511f0f242d07cb1ebe6a5b6db417262b9d2568b460347c57d95cc/wcwidth-0.8.1.tar.gz", hash = "sha256:faf5b4a5366a72dc49cad48cdf21f52bdf63bdda995178e483ba247ff79089b9", size = 1466072, upload-time = "2026-06-08T05:57:23.146Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/17/c68b6cbcfeadbf420b3c3edaf8fda51335bc9c38732adb2d3ba8984dc607/wcwidth-0.8.0-py3-none-any.whl", hash = "sha256:8c75e6099cefd197c4bcc67a486f70b5dbc68f997c05f34a811d853910450d64", size = 324935, upload-time = "2026-06-05T21:19:33.999Z" },
{ url = "https://files.pythonhosted.org/packages/bd/6e/95b0e537de1f4d4301f76f944642c6da50d1511cc7b3d64dc418a66c7509/wcwidth-0.8.1-py3-none-any.whl", hash = "sha256:f453740b1e4a4f3291faa37944c555d71056c4da08d59809b307ef4feba695c8", size = 323092, upload-time = "2026-06-08T05:57:21.413Z" },
]
[[package]]