Commit Graph

7 Commits

Author SHA1 Message Date
David McKay f3f32aafff fix(showcase): tighten the offsite-expenses beat
The "Sort out my offsite expenses" beat had three problems on stage: the
harness console was a black slab in a light-mode transcript, the tool
activity grew a stack of finished steps that pushed the report card off
the screen, and the run took a full minute.

Console (banking skin):
- Every colour is now a semantic token, so the pane follows the app into
  dark or light instead of being hardcoded dark. It was `bg-ink` with
  `text-white/45`-style overlays, which only ever looked right in one mode.
- Collapsed by default. The status strip still carries the live state, so
  the run reads as alive while closed.
- Still the FULL log when open. It is the detail view, and windowing it as
  well left two lines and nowhere to read the rest.

Tool activity (shell, all skins):
- Rolls to the last two lines; older ones are REMOVED, not collapsed.
- Registration is a layout effect, and that is load-bearing. A new line
  renders before it is registered, and registering is what evicts the
  oldest, so with a passive effect the browser painted the in-between
  state: three rows for one frame on every tool call, and again when the
  end-of-run MESSAGES_SNAPSHOT remounts every line at once. Measured per
  animation frame over a full run: 12,734 frames, never more than two.
- A shared registry rather than something simpler because CopilotKit
  renders one component per tool call and owns the container. CSS
  `:nth-last-child` needs siblings and the lines had one parent each;
  mount-order counters drift across the snapshot remount.

Agent (run time 1m 0s -> ~45s):
- Research is gated on the offsite dates. A charge dated outside the
  window is settled by its date whatever the merchant turns out to be, so
  half the researcher dispatches were buying nothing. Travel on the
  adjacent days is still kept in scope.
- Filings go out in one command instead of one curl per row. The
  researchers already ran concurrently, so the serial per-row round-trips
  through the model were most of the wall clock, not the research.
- Fetch and verify are one command; there is nothing to decide between
  the halves.
- Analyst reasoning effort defaults to medium, overridable with
  BANKING_EXPENSE_EFFORT.

Filing is idempotent, which the batching made necessary: the script got
run twice and every charge was filed twice, so the report card claimed six
filings while the ledger held twelve. The script now writes `filed.json`
and exits early if it exists. That marker is cleared once per run, because
the workspace is a fixed directory shared by every run and a stale marker
would convince the next demo it had already filed and post nothing at all
— the same bug wearing the opposite mask, and a quieter one, since a run
that files nothing still writes a confident report.

Deduping server-side on merchant+amount would have been wrong: Hotel
Verrano legitimately appears twice at the identical 318.55 for the two
nights of the offsite.

Does this change make anything in .claude/skills/reskin/ wrong,
incomplete, or misleading? No. Nothing there documents the harness
console, the tool-activity renderer, or the analyst prompt; its only
matches for "console" are `console.error` in unrelated template code.

Verified on the real path against a live Intelligence stack: six rows
filed, all status=pending, stable across 60s of polling, and present in
the Pending Approval queue with their notes and approve/decline actions.
2026-08-20 13:09:25 -07:00
Maxim cf9de905ac fix(reskinnable-demo): pin and lock the agent's Python canaries
A colleague cloning this branch could not reproduce the demo. The JS half
is pinned exactly — this app left the root pnpm workspace and ships its
own `pnpm-lock.yaml` with `@ag-ui/client 0.0.59-canary.1786716392.0` — but
the Python half pinned nothing: `ag-ui-langgraph>=0.0.43` and no
`uv.lock`, so a fresh `uv sync` resolved the RELEASE.

Measured, on the release that `>=0.0.43` actually selects:

    ag-ui-langgraph==0.0.43
      emit_subagent_events accepted by LangGraphAgent.__init__:  False
      subagent symbols in ag_ui.core:                            NONE

That is this branch's headline feature — the streaming CLI console — and
it would have failed SILENTLY. `main.py` sets `emit_subagent_events` as an
attribute (copilotkit's subclass takes only four kwargs), so on a release
without the feature the assignment succeeds, lands on an object nobody
reads, and the service starts clean. No `subagentRunId` reaches the
browser, the console cannot separate the harness's work from the parent's,
and a reopened thread collapses a multi-minute run to one tool message.
Every gate stays green.

So `ag-ui-langgraph` and `ag-ui-protocol` are now `==` pins, and
`agent/uv.lock` is committed. `ag-ui-protocol` is pinned as a DIRECT
dependency although nothing imports it by name: it carries the SUBAGENT_*
event types and the adapter asks only for `>=0.1.15`, so left transitive
it resolves the release and undoes the other pin.

README: the quick start said `pnpm install # from the repo root — this is a
workspace package`. It is not one — it is absent from
`pnpm-workspace.yaml`, deliberately, so the canary line cannot leak into
the rest of the monorepo. A root install therefore installs nothing for
this app, which is a confusing first five minutes for anyone who reads it
and follows it.

Verified by cold start rather than by inspection: copied ONLY the files
git tracks (the five .py modules, pyproject.toml, the new uv.lock) into an
empty directory, ran `uv sync --frozen`, and got
ag-ui-langgraph 0.0.43.dev1786716392 with `emit_subagent_events accepted:
True` and the five SUBAGENT symbols present. Then booted that venv on a
spare port and replayed the browser's captured Q2-with-PDF payload
through it: RUN_FINISHED, with createReport carrying the invoice's real
line items. The live stack was not touched.

Reskin skill: checked, no impact. Its install/verify step is `pnpm dev`
inside this app, which is correct either way; the root-vs-here distinction
is a README concern and the skill never mentions the workspace.
2026-08-20 17:49:18 +02:00
Maxim 11838db020 fix(reskinnable-demo): carry an attached PDF to the model as a file block
Beat 3d was dead on banking: clicking the Q2 pill staged the invoice,
sent the message, and then nothing happened at all — no report, no error,
no failed message in the transcript.

`ag_ui_langgraph` hands every attachment to the model as an `image_url`
block, documents included, so the PDF was rejected before the first
token:

    openai.BadRequestError: 400 - Invalid MIME type. Only image types are
    supported. (code: invalid_image_format)

The exception is raised inside the model node, which kills the SSE
stream. The runtime sees `RUN_ERROR: terminated` with no cause and the
browser renders nothing. Worse, the crashed run is still checkpointed, so
every LATER message on that thread replays the rejected content and dies
the same way: one click on the pill killed the whole conversation, and
only restarting this service cleared it (`MemorySaver` is in-process).

`_repair_document_attachments` rewrites those blocks into LangChain
standard `file` blocks before the model call. It walks every message, not
just the newest, because the offending content comes back from the
checkpoint on later turns.

STOPGAP, not a design. The real fix is open upstream as
ag-ui-protocol/ag-ui#2476 (both adapters, plus the return leg so a
non-image attachment survives MESSAGES_SNAPSHOT). This service installs
the adapter from PyPI, so that fix cannot reach this venv until it is
published; the middleware's header says when to delete it.

No test: `agent/` has no python test runner, and standing one up for code
whose deletion is already scheduled is the wrong trade. The durable
tests — PDF, audio, video, filename, round-trip, legacy binary — ship
with the upstream PR instead. Verified here by replaying the browser's
real captured run payload against the service: 400 before, RUN_FINISHED
with `createReport` carrying the invoice's line items after.

Reskin skill: checked, no impact. Its beat-3d guidance is entirely the
CLIENT half (staging into the composer, the `AttachmentFailureCause`
union, do not copy `@/shell/attach`), and it names no model-side
conversion. A skin authored from the skill gets a `BuiltInAgent`, whose
converter already maps documents onto file parts — banking is the only
skin whose agent is a LangGraph service, so this failure is unreachable
from the skill's path.

Gates: lint clean, typecheck clean, 2460 unit tests pass. `pnpm build`
deliberately not run — the diff touches no TypeScript, and `next build`
would clobber the `.next` state of the dev server currently serving the
demo.
2026-08-20 17:17:03 +02:00
Maxim 0163beab8e feat(reskinnable-demo): stream the harness into a CLI console via AG-UI subagents
Makes the offsite-expenses beat legible while it runs, and gives it its own
model, by taking the AG-UI subagent surface from the canary line.

## The expense analyst is now a real subagent

    banking                       gpt-5.4, temp 0
      │  banking's prompt; browser frontend tools; Intelligence memory tools
      └─ expense-analyst          gpt-5.6-sol, reasoning_effort=high
           │  sandboxed shell, submit_expense_report
           └─ merchant-researcher gpt-5.4, one per merchant, Tavily

Previously the beat was a section of banking's prompt, which left nowhere to put
per-beat configuration: model, effort and recursion limit are all agent-level and
there was one agent. It also meant banking's ~21,000-character rulebook rode
every one of the ~20 model calls the run makes, re-sending rules about markdown
tables while the agent read a CSV.

Reached as a `CompiledSubAgent` because a raw `SubAgent` spec has no `subagents`
field and this one needs its own — the per-merchant fan-out is a headline of the
beat, and a flat subagent could only research serially. Verified nesting
survives: a probe showed the analyst's `task` dispatches, the researchers'
`search_merchant` calls and the final report tool all reaching `astream_events`.

`gpt-5.6-sol` additionally needs `use_responses_api=True`: with function tools
and `reasoning_effort` it 400s on /v1/chat/completions. The first probe missed
that by asking the model a question with NO tools bound — a model probe for an
agent has to bind one.

## The console: one CLI window, streaming

`shell/subagents/subagent-activity.tsx` subscribes to the agent's event stream
and folds it into console lines. Reading `agent.messages` (the previous design)
was wrong twice over: messages materialise at the `MESSAGES_SNAPSHOT`, two per
run, so the pane sat still for minutes and then filled at once; and persisted
messages carry no `subagentRunId`, so the harness's narration could not be told
from banking's own reply.

The fold is pure and idempotent — every line keyed by the id of the thing that
produced it — so the same code serves the live subscription and a replay of the
thread's stored events when a conversation is reopened.

Three heuristics are deleted, each replaced by identity the protocol already
carried:

- the console's "first tool call" anchor      -> the run's first `task` call, from
                                                 MESSAGE order (durable; the
                                                 event-derived version rendered
                                                 one console per delegation on a
                                                 restored thread — six, measured)
- `CONSOLE_TOOL_NAMES` suppression list       -> `subagentRunId` presence
- `disable_streaming` on the researchers      -> the canary's per-lane state

`shell/subagents/subagent-message-filter.tsx` keeps subagent narration out of the
conversation. It suppresses the PROSE and keeps the TOOL CALLS: an agent
routinely narrates and calls a tool in one message, and returning null for the
whole message hid the REPORT CARD — the run looked perfect and ended with nothing
to show. Shell-level and inert for a skin whose agent has no subagents.

## Canary stack, contained to this app

The subagent surface only exists on the canary line, and a released
`@ag-ui/client` <= 0.0.57 rejects `SUBAGENT_*` events in the HTTP transport
before any middleware runs, killing the stream. So the demo leaves the root pnpm
workspace and ships its own lockfile, pinning `@copilotkit/* 1.68.3-canary` and
`@ag-ui/* 0.0.59-canary` locally instead of imposing an unreleased protocol on
every package in the monorepo.

A 1.62.2-based canary was tried first and could not compile the app: it silently
rewound the CopilotKit API five minors under a demo written against 1.67.1, and
`OpenGenerativeUIActivityRenderer` (a public `/v2` export since ~1.63) was the
first thing to break. `workspace:*` is not a version, so the app had no recorded
lower bound on the API it needs.

KNOWN GAP, deliberately not fixed here: Nx discovers projects THROUGH the pnpm
workspace (there is no `workspaceLayout` in `nx.json`), so leaving it also
removes the demo from the repo-wide `nx run-many -t build` and `-t check-types`
sweeps. Verified — `nx show project deep-agents` and the other standalone
showcases return "Could not find project". No workflow names this demo, so it is
currently unbuilt and untype-checked in CI and needs its own job. Run the four
gates locally until that lands. Documented in `pnpm-workspace.yaml`.

## Fixes

- The run clock is keyed per run and read through an injected `ToolRuntime`
  instead of taking the oldest open stamp across the process. Model calls AFTER
  the report re-stamped the clock and that leftover became the next run's start:
  a two-minute run reported 333s. Now 86s reported against 98s wall clock — the
  gap is thread-naming and delegation, before the analyst's first model call,
  which is what the tile claims to measure.
- `merchantKind` non-answers are rejected on the leading token, on hedging
  language, and over 40 characters. With no search tool the model wrote a bare
  "unclear"; with Tavily live it hedges in prose ("unknown (likely
  bookbindery/bookshop retail, but not established for this exact merchant)"),
  which an exact-match filter passed into a 60-character label glued to the
  merchant name.
- `vitest` no longer externalises `@copilotkit/*`. Installing them from npm moved
  `src/app/layout.tsx`'s stylesheet import under `node_modules/.pnpm/`, where
  Node's ESM loader threw `Unknown file extension ".css"` and took out 16 suites
  while naming a stylesheet nobody had touched.
- `agent/main.py` reads the demo's `.env` as well as its own, so `TAVILY_API_KEY`
  works wherever an operator puts it. Two env files to keep in sync is a trap
  whose failure mode is "the agent ignores a key that is plainly sitting in .env".

## Upstream finding (reported separately, not fixed here)

`@copilotkit/runtime` drops `subagentRunId` when persisting messages: 2888 of
3026 stream events carry it, 0 of 53 persisted messages do. Reproduced with
Intelligence removed entirely, so it is the runtime's message shape rather than
the platform store — and `@copilotkit/runtime`'s dist contains no occurrence of
the field at all, while `@ag-ui/core`, `ag-ui-protocol` and `@copilotkit/core`
all model it. One field threaded through would let the console rebuild from
message history and delete the event-replay seeding added here.

`CLAUDE.md`'s appended block is generated by `next dev`
(`next/dist/server/lib/generate-agent-files.js`) and committed per its own
instruction to keep the tree clean.

Gates: lint 0, typecheck 0, test:unit 2460 passed across 216 files, build 0.
Measured end to end in Intelligence mode: 14 rows, 9 merchants researched, 6
charges filed with ids read out of real 201 bodies, totals reconciling against
their own rows, 3220 events with 6 SUBAGENT_STARTED/FINISHED pairs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JRubZT6AS6LCGkcE2KzcfA
2026-08-20 13:54:58 +02:00
Maxim a29bf0720a feat(reskinnable-demo): add the offsite-expenses pill and harden the report fields
THE PILL. The offsite-expenses beat had no entry point in the UI. The agent was
wired, the service was up, all four gates were green, and every verification I
ran POSTed directly at the runtime — so the beat was fully working and completely
unreachable for anyone opening the app. It is the ninth and last pill, after the
AWS charge, because it is the only one that does not answer in seconds.

`suggestions.test.ts` now pins it. That guard is not decoration: this pill has no
type referencing it, no matcher and no route, so nothing else in the tree
notices if it is dropped or retitled. It checks REACHABILITY, which is the
property nothing else was holding.

MERCHANT KIND HARDENING. `merchantKind` is a KIND ("hotel", "pharmacy") that the
report card prints inline beside the merchant name, where it has room for about
two words. The previous filter rejected exact matches of "unclear"/"unknown",
which was enough when there was no search tool and the model wrote a bare
"unclear". With Tavily live it hedges in prose instead — measured:
"unknown (likely wellness-related business)" and "unknown (likely
bookbindery/bookshop retail, but not established for this exact merchant)". Both
sailed through and would have rendered as a 60-character label glued to the
merchant name.

Now rejects on the leading token, on hedging language anywhere, and on anything
over 40 characters. The length cap is the backstop: it encodes the actual
constraint rather than a list of phrasings already observed, so it catches the
next wording nobody predicted. A measured run after the change leaked none, with
a longest surviving kind of 23 chars.

ONE ENV FILE. `agent/main.py` now loads the demo's `.env` in addition to its own,
with `agent/.env` winning on conflict. The app and this service need the same
keys, and asking an operator to keep two files in sync is a trap whose failure
mode is "the agent ignores a key that is plainly sitting in .env" — which is
exactly what happened when TAVILY_API_KEY was added to the demo's `.env` and the
service, reading only its own, never saw it.

`.env.example` documents TAVILY_API_KEY and BANKING_AGENT_URL, including what a
keyless run actually looks like: the research subagents are told plainly that no
search happened and instructed to report "could not establish" rather than
guess, so the run completes with several rows marked `unclear` and a "merchants
researched" tile reading 0. Nothing errors, which is why it needs writing down.

Measured with Tavily live: 14 rows, 10 merchants researched, 10 parallel
subagents, 31s, totals reconciling against their own rows, 7 charges filed with
ids read out of real 201 bodies, zero non-answer kinds. Research also CHANGES a
decision rather than only labelling one — The Copper Room resolved to
`restaurant_lounge` and moved from `unclear` to `expensable`.

Gates: lint 0, typecheck 0, test:unit 2460 passed across 216 files, build 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JRubZT6AS6LCGkcE2KzcfA
2026-08-20 02:42:45 +02:00
Maxim e4eb96f8d7 feat(reskinnable-demo): make banking's agent the Python deep agent
Banking's `agent.ts` stops returning a `BuiltInAgent` and returns an `HttpAgent`
pointed at the Python deep agent instead. Six skins still run in-process;
banking is the one that does not.

WHY THE WHOLE AGENT, not just the offsite-expenses beat. Threads are scoped per
agent — `listThreads` takes `agentId` as a REQUIRED parameter, and measured
against the running app the two lists are disjoint (banking 46 threads,
banking-expenses 10). The v2 runtime has no handoff mechanism, and
`defineTool`'s `execute` takes `(args)` with no emitter, so a tool cannot stream
a multi-minute run. Under a second agent id a presenter could start the
analysis, switch threads, and have nothing to come back to. One conversation
list means one agent.

The expense task spec is appended to banking's own prompt as a CONDITIONAL job
rather than a second identity — it opens by saying it applies only when the user
hands over a statement, and to ignore it otherwise.

Ported to Python:
- `agent/prompt.py` — banking's system prompt, extracted from the TS template
  literal programmatically and asserted equal, not paraphrased. 21,208 chars
  carrying 56 distinct rules; a dropped rule silently breaks a beat that still
  looks like it works.
- `agent/report.py` — `render_report` and the a2ui operations builder. Component
  order, ids and the `columns` arithmetic match the TS builder; the unique
  surface suffix uses uuid rather than a timestamp so two reports in the same
  millisecond cannot collide.

Carried over from the TS `BuiltInAgent` because each had a reason written beside
it: the non-mini model (the teach-and-recall arc routes unreliably on mini) and
`temperature: 0` (tool routing must be deterministic).

Also sets `emit_raw_events = False`. It defaults to True, which piggybacks
LangChain's internal events onto the AG-UI stream: a measured run streamed
27,950,261 bytes, and the same run with it off streamed 374,086 — a 75x
reduction with an identical report. This matters more than it looks because the
thread PERSISTS those events for replay, and leaving a running thread and coming
back to it is the point of this beat. Nothing downstream reads RAW.

Verified against the running app, not reasoned about:
- `/info` reports banking as HttpAgent and the other six as BuiltInAgent.
- Canvas beat: `render_report` -> TOOL_CALL_RESULT -> the a2ui middleware emits
  `activityType: a2ui-surface` with createSurface + updateComponents.
- Frontend-tool beat: given showTransactions + showPendingApprovals the agent
  picks showTransactions, emits no result (the client executes it) and emits no
  prose — the prompt's "the rendered list is the single source of truth" rule
  surviving the port.
- Intelligence mode: the run is handed the browser's tool AND
  recall_memory / save_memory / forget_memory / copilotkit_knowledge_base_shell.
- Durability: a run whose client disconnected at 8s finished unattended and left
  a replayable thread.

Removes `expenses-agent.ts` and the non-skin `banking-expenses` registry key;
banking's own id now serves both. `AgentRegistration.createAgent`'s comment is
rewritten — it described a second remote key that no longer exists — and now
gives a grep to derive the BuiltInAgent/HttpAgent split rather than asserting it.

Gates: lint 0, typecheck 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JRubZT6AS6LCGkcE2KzcfA
2026-08-20 01:56:24 +02:00
Maxim a6418bcc09 feat(reskinnable-demo): run the offsite-expenses beat on a LangChain deep agent
Replaces the Codex CLI subprocess with a Python LangChain deep agent
(`examples/showcases/reskinnable-demo/agent/`) reached over AG-UI: sandboxed
shell execution via deepagents' `LocalShellBackend`, parallel per-merchant
research subagents, no external harness binary, and our own API key.

Registered as `banking-expenses` in the server agent registry. That required
widening `AgentRegistration.createAgent` from `() => BuiltInAgent` to
`() => AbstractAgent` — the runtime's own `agents` option is
`Record<string, AbstractAgent>` and the v2 tree contains no
`instanceof BuiltInAgent` branch, so the demo's type was stricter than the
runtime it described.

Measured end to end against the real runtime: 58 `execute` calls, 7 concurrent
`task` subagents (peak concurrency 4/4 on a dedicated probe), RUN_FINISHED,
eight charges filed with ids read out of real 201 bodies, and 8221 replayable
AG-UI events persisted on the thread after the client disconnected 8s in.

Correctness fixes found by running it rather than reading it:
- `submit_expense_report` DERIVES the totals from the verdicts instead of
  accepting them as arguments. A measured run had every per-row amount matching
  the CSV while the headline totals came back $1.00 and $0.20 high — the model
  authored them. The report card prints the total directly above the rows it is
  the sum of, so tiles and rows must not be able to disagree.
- `amount` is coerced at the tool boundary; it arrived as a string on all 14
  rows, which silently defeats the widget's currency formatting.
- `merchantKind` values that are non-answers ("unclear") are dropped rather than
  rendered beside the merchant name as if they were findings.
- The recursion limit is set on the AGENT, not via `graph.with_config(...)`,
  which the AG-UI adapter drops — the run completed the whole analysis and then
  died at LangGraph's default of 25 supersteps.
- A `clone()` override works around `copilotkit` 0.1.95 being incompatible with
  `ag-ui-langgraph` >= 0.0.42, whose base `clone()` (called per request) passes
  kwargs the SDK subclass does not accept. Every request 500s without it.

Deletes the superseded Node-side harness scaffolding: `prompt.ts` (the task
specification now lives in the Python agent's system prompt), `workspace.ts`
(the sandbox owns the workspace) and their tests. `types.ts` stays — it is the
shared vocabulary the report card and the tool renderer both import.

Also drops the three unused `@tanstack/*` dependencies.

Reskin-skill impact: checked. `.claude/skills/reskin/` documents the `Skin`
contract, and no field of it changed — `banking-expenses` is NOT a skin and must
never look like one. It is absent from `registry.ts`, `skinIds`,
`skinIdentities` and `LINTED_SKIN_IDS`, and has no route, theme or identity. The
one documented thing that did change is `AgentRegistration.createAgent`'s type,
which SKILL.md describes in its registration section; that is updated in the
follow-up commit that makes banking itself a deep agent.

Gates: lint 0, typecheck 0, test:unit 2458 passed across 217 files, build 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JRubZT6AS6LCGkcE2KzcfA
2026-08-20 01:07:56 +02:00