Skip to content

feat(backend): code sub-agent + lazy-register key-needing tools + shared UI primitives#5

Merged
FireTable merged 14 commits into
mainfrom
chore/code-agent-lazy-reg-tooling
Jun 28, 2026
Merged

feat(backend): code sub-agent + lazy-register key-needing tools + shared UI primitives#5
FireTable merged 14 commits into
mainfrom
chore/code-agent-lazy-reg-tooling

Conversation

@FireTable

Copy link
Copy Markdown
Owner

Summary

Three intertwined improvements, split into 12 commits for clean review:

1. Lazy-register tools that need a third-party key

  • search_web (JINA), get_NFT_holdings (ALCHEMY), and the new execute_code (DENO_DEPLOY_TOKEN) are now StructuredTool | null and conditional-spread into the tool list. Missing key → tool is invisible to the model → graceful degradation instead of mid-conversation 401s.
  • fetch_url stays unconditional (r.jina.ai works without auth on the free tier); lib/jina.ts falls through to a no-Auth fetch when the pool is empty.
  • New CLAUDE.md rule [Bug]: Memory tab Socials row missing email per linked provider #10 codifies the pattern.

2. New code sub-agent (TypeScript / JavaScript / Python)

  • write_code interrupts with an editor card; user reviews/edits and clicks Run.
  • execute_code runs the approved code in a Deno Deploy Sandbox (Firecracker microVM). TS/JS go through deno eval; Python routes to python3 -c (CPython 3.13, stdlib only).
  • Wired into both graph topologies (USE_SUBGRAPH=true and the inlined default) with a new codeAgent enum value on the router.
  • ANSI color escapes are stripped at the boundary so subprocess output is plain text the UI can render.

3. Shared card chrome primitives

  • CardShell / CardHeader / ErrorBanner / SuccessBanner under components/tool-ui/primitives/. Every tool-ui card repeats this chrome; previously inlined in each one.
  • AskLocationCard is the first migration target. Other tool-ui cards (code/weather/crypto) follow in subsequent PRs.
  • New CLAUDE.md rule [Feat]: Public landing page at / (replace auto-redirect to /chat) #11 codifies the pattern + two non-obvious details (the min-w-0 flex-1 + break-all combo for stack-trace lines, and the flat-icon rule).

Test plan

  • pnpm test — 349/349 pass
  • pnpm lint — clean (only pre-existing warnings in weather-runtime-core.generated.ts, untouched by this PR)
  • pnpm format:check — clean
  • TypeScript — no new diagnostics beyond the pre-commit feat(crypto): crypto sub-agent + NFT gallery on LangGraph #4 staging expectations (already resolved in-tree)

Commits (12)

  1. chore: add @deno/sandbox and prism-react-renderer deps
  2. refactor(lib): jinaFetch falls through to no-Auth when pool is empty
  3. refactor(backend): lazy-register key-needing tools (search_web, get_NFT_holdings)
  4. feat(backend): add code sub-agent with Deno Deploy Sandbox executor
  5. feat(frontend): shared code-block + inline syntax highlighter
  6. feat(frontend): code tool cards (write_code editor + execute_code result)
  7. feat(frontend): add code-writing assistant suggestion
  8. refactor(frontend): extract shared card chrome to primitives + apply to ask-location
  9. docs(tools): code agent, lazy registration, shared UI primitives, API key table
  10. docs: .env (DENO + lazy-registration notes), INTERRUPT write_code, APIS nit, TODOS trim
  11. chore(docs): CLAUDE.md rules for lazy-registration + card primitives
  12. style: oxfmt pass across cards, globals, and langgraph-proxy test

Configuration

  • DENO_DEPLOY_TOKEN (required for execute_code to register) — free at https://console.deno.com/ → Sandbox tab.
  • DENO_DEPLOY_ORG — only required for personal tokens (ddp_*); org tokens (ddo_*) don't need it.

When DENO_DEPLOY_TOKEN is unset, write_code still works and execute_code is omitted from the agent's tool list — the model surfaces a graceful fallback on Run instead of a 401.

🤖 Generated with Claude Code

FireTable added 14 commits June 29, 2026 01:49
@deno/sandbox powers the upcoming code sub-agent's execute_code tool (Deno Deploy Sandbox, Firecracker microVM). prism-react-renderer is the new syntax highlighter used by the chat markdown code block and the tool-ui code card — replaces the previous hand-rolled tokenizer.
r.jina.ai accepts unauthenticated requests on the free tier — an empty JINA_API_KEYS pool should still serve the reader. Previously the fetch threw "pool is empty" before any request went out. s.jina.ai still requires a key; tools that depend on it (search_web) gate their registration on a non-empty pool so this branch is never the failing path.
…FT_holdings)

Both tools now return `StructuredTool | null` — null when their server-side key is unset. `backend/tool/index.ts` `...`-spreads the conditional into the tool list so the model never sees a tool that 401s on every call. The "no key" error path becomes a graceful degradation (the model just doesn't know the tool exists) instead of a mid-conversation 401. `fetch_url` stays unconditional since r.jina.ai works without auth on the free tier.
New `codeAgent` subgraph sits beside weather/chat/crypto and is wired into both the inlined and subgraph graph topologies. The router can now send code-asks ("write a function", "compute X", "跑代码") here instead of dumping them into the generic chat agent.

The subgraph exposes two tools:
- `write_code` pauses via interrupt() so the user reviews/edits in a card and clicks Run; the resume payload carries the (possibly edited) code + language back to the model.
- `execute_code` runs the code in a Deno Deploy Sandbox (Firecracker microVM) via `sandbox.spawn`. `s.jina.ai`-style key-gating applies here too — the tool is `StructuredTool | null` based on `DENO_DEPLOY_TOKEN`, and is omitted from the agent's tool list when the token is missing. Stripping CSI ANSI escapes happens at the boundary so the result type stays a clean string.

`backend/tool/code/` is the home of these three (write_code, execute_code, deno-run) plus a barrel re-export. The code split out from the previous single index.ts so each file stays small and the lazy-registration boundary is obvious. `scripts/test-deno-sandbox.mts` is a one-shot smoke test for verifying the token wires up — delete after first green run.
prism-react-renderer slots under `SyntaxHighlighter` with a theme that flips on `next-themes` (github / vsDark) and a transparent background so the parent's bg-muted/30 shows through. `CodeBlock` is the chrome (header + pre) the tool-ui code cards reuse — same look as a chat-side fenced code block, just no parser step.

The markdown code component now also fixes a long-standing empty-inline-code rendering bug (the prior `<code>` was missing `{children}` in the non-codeBlock branch) and routes language-tagged inline code through `InlineCode` for tokenized colors. The plain pill remains for inline code without a language hint — tokenizing without a grammar adds noise without color.
…ult)

Two new cards in `components/tool-ui/code/`, registered in the toolkit alongside the existing weather/crypto toolkits:

- `WriteCodeCard` — the user-side approval point for `write_code`. Shows the proposed code in a CodeBlock (default TypeScript; JavaScript and Python supported) and pairs Run with Skip run. The card reads `args.code` on every render so streaming tool-call payloads land without a stale-state lag, and propagates the chosen language into the resume payload.
- `ExecuteCodeResult` — read-only result card for `execute_code`. Renders Result / Stdout / Stderr as labelled CodeBlocks and the error path through `ErrorBanner` (monospace flag for stack frames). Icon flips to a destructive tint on failure.

The card uses the shared `CardShell` / `CardHeader` / `SuccessBanner` primitives, so the chrome stays consistent with the other tool-ui cards.
Adds a "Write a TypeScript function to solve Two Sum" suggestion alongside the existing weather/crypto/NFT starters so the new code sub-agent is discoverable from the empty state.
…to ask-location

Every tool-ui card repeats the same chrome — rounded border shell, icon-circle header, and a success / error banner. Re-typing these in each new card is a bug factory: the same overflow / spacing / icon-size fix has to be applied in N places.

Four primitives land in `components/tool-ui/primitives/`:
- `CardShell` — outer wrapper + inner flex container, `maxWidthClass` for the connect-wallet-style modal (`max-w-md`) vs. the default `max-w-2xl`.
- `CardHeader` — icon circle + title + optional subtitle + optional trailing slot.
- `ErrorBanner` — destructive surface; `monospace` flag switches to `font-mono text-[11px] leading-relaxed whitespace-pre-wrap break-all`. The `break-all` is what stops long error lines (no spaces, e.g. Deno stack frames like `file:///home/app/$deno$eval.mts:1:7`) from overflowing — `whitespace-pre-wrap` alone doesn't help when there's nothing to break on.
- `SuccessBanner` — neutral muted surface for resolved states.

AskLocationCard is the first migration target: it previously inlined the shell / header / banner / pill, now composes the four primitives. Other tool-ui cards (code/weather/crypto) will follow in subsequent PRs.
… key table

Brings `docs/TOOLS.md` up to date with the actual state of the code:
- New Code section covering `write_code` + `execute_code`, with the lazy-registration caveat for `execute_code`.
- New "Lazy registration (env-var-gated tools)" section explaining the pattern and the one `fetch_url` exception.
- New "Tool ↔ API key" table — single source of truth for which tool depends on which server-side env var, with notes on the optional/required distinction.
- New "Shared UI primitives" section documenting `CardShell` / `CardHeader` / `ErrorBanner` / `SuccessBanner` so future cards reach for them instead of re-typing the chrome.
- New "Sanitizing subprocess output" section explaining the `stripAnsi` boundary in `deno-run.ts` so any new subprocess-capturing tool follows the same pattern.
…IS nit, TODOS trim

`.env.example`:
- Adds `DENO_DEPLOY_TOKEN` / `DENO_DEPLOY_ORG` with the ddo_/ddp_ distinction and free-tier quota note.
- Documents that `JINA_API_KEYS` is required for `search_web` but optional for `fetch_url` (r.jina.ai free tier), and that `ALCHEMY_API_KEY` gates `get_NFT_holdings` (tool is not registered when the key is missing).

`docs/INTERRUPT.md`: Adds the `WriteCodeResume` payload type for the new code sub-agent's interrupt step.

`docs/APIS.md`: Whitespace-only fix to the get_nft_holdings table (long URLs had over-stretched the markdown columns).

`docs/TODOS.md`: Trim trailing blank line.

`README.md`: Env-var table re-wrapped to match the wider table style after JINA/ALCHEMY rows were rephrased.
Two new engineering rules that codify the patterns introduced in this PR:

**#10 — Tools that need a third-party key MUST be lazy-registered.** A tool whose third-party API requires a server-side key (`search_web` → `JINA_API_KEYS`, `get_NFT_holdings` → `ALCHEMY_API_KEY`, `execute_code` → `DENO_DEPLOY_TOKEN`) is defined as `StructuredTool | null`, gated on `process.env.<KEY>` at module load, and spread into the tool list with `...(tool ? [tool] : [])` so a missing key drops it from the agent's view. `fetch_url` is the one exception (r.jina.ai works without auth on the free tier).

**#11 — Use `components/tool-ui/primitives/` for card chrome — don't inline it.** CardShell / CardHeader / ErrorBanner / SuccessBanner are the canonical chrome. Two non-obvious details baked into the primitives are spelled out so they don't get re-broken by a "quick inline" later: the `min-w-0 flex-1` + `break-all` combo for long stack-trace lines, and the flat-icon rule (the circle is already drawn by the wrapper, a circle inside a circle looks small). Adding a new primitive means updating the "Shared UI primitives" section in `docs/TOOLS.md`.
Formatter-only changes — no behaviour change. Touched up the trailing newline on ToolCardSkeleton, single-line classNames on the remaining tool-ui cards, the font-family stack in globals.css, the Alchemy proxy handler signature, the generated weather runtime, and the langgraph-proxy test mocks.
… hint

The previous wording listed mixed Chinese/English trigger phrases ("帮我写个脚本", "算一下", etc.) which is brittle for an LLM router — phrase lists don't compose well with paraphrasing, and mixing languages makes the rule feel like a lookup table instead of a semantic condition.

Replace with a semantic description: precise numeric calculation, formula evaluation, data conversion / transformation, multi-step scripting, or function generation. The router still falls back to the explicit verbs ("write", "compute", "calculate", "convert", "parse", "run") so the common phrasings are still covered.
Wires the previously-dead `input` arg end-to-end:
- `DenoRunOptions.input`, `execute_code` zod schema, and the toolkit `parameters` shape now carry it through.
- `denoRun` chooses `stdin: "piped"` when `input !== undefined`, otherwise `stdin: "null"` so the child sees an immediately-closed read end.
- Strings are written verbatim; other values are JSON-serialized. The child decides how to read it (Deno: `new Response(Deno.stdin.readable).text()`, Python: `sys.stdin.read()`).
- The stdin write + close are subject to the same `Promise.race` timeout as `child.output()` so a hung process is still reaped via SIGKILL.

`execute-code-result` Args type picks up `input?: unknown` for schema parity (the card itself doesn't render it — `input` is an arg, not a result).
@FireTable FireTable merged commit 2275a19 into main Jun 28, 2026
@FireTable FireTable deleted the chore/code-agent-lazy-reg-tooling branch June 28, 2026 18:07
@FireTable FireTable restored the chore/code-agent-lazy-reg-tooling branch June 30, 2026 09:38
@FireTable FireTable deleted the chore/code-agent-lazy-reg-tooling branch June 30, 2026 09:40
FireTable added a commit that referenced this pull request Jul 13, 2026
…ry cast

No behavior change.
- registry: drop the through-unknown cast (ChatOpenAI IS a BaseChatModel) and rewrite the 'build all N first' rationale — keep the cost rationale, drop the speculative 'future per-tuple apply bindTools' YAGNI comment.
- admin ProviderCard: 4-line ponytail comment on a 1-line className → 2 lines (rule #5: explain why, briefly).
- badge.tsx: reformat destructive variant to multi-line, matching success's shape after a linter pass single-lined it.
FireTable added a commit that referenced this pull request Jul 13, 2026
…cks chain (#36)

* feat(providers): round-robin + withFallbacks across provider keys (issue #14)

Replaces random-pick + single-key model resolution with deterministic
round-robin across every enabled (provider, model, key) tuple. The
returned runnable is wrapped via LangChain's withFallbacks so a
retryable error on the primary walks the rest in order.

No priority field — tuples are sorted by (providerId, modelName,
keyName) and the rotation is modulo N. Counter is process-local;
per-process fairness is sufficient for a single-VPS self-host.

Cache: tuple list (decrypted blobs + baseUrl + model names) is
cached in a 60s LRU keyed on providerId:modelName. The wrapped
RunnableWithFallbacks is rebuilt on every call so round-robin can
advance — the previous 'cache the ChatOpenAI' model couldn't
rotate.

Docs: docs/PROVIDERS.md rewritten to describe round-robin + chain;
docs/DB.md line 174 reflects new resolution path; CLAUDE.md docs
index picks up PROVIDERS/CREDIT/ADMIN/DEPLOY. New CLAUDE.md rule
#13 forbids ad-hoc test scripts under scripts/ (use /tmp).

Refs #14.

* fix(providers): drop withFallbacks wrap that broke bindTools + withStructuredOutput

RunnableWithFallbacks is Runnable, not BaseChatModel — drops .bindTools / .withStructuredOutput. The 6 LangGraph node consumers crashed at runtime whenever a >=2-tuple round-robin landed on the wrapped primary. Return the bare ChatOpenAI picked by round-robin; cross-tuple retry on error is intentionally gone (see ModelTuple comment). Adds a regression test that pins bindTools / withStructuredOutput as functions on every >=2-tuple round-robin pick so a future re-introduction of withFallbacks flips it red.

* docs(providers): document round-robin without fallback chain

Drop 'withFallbacks' from the resolution-path step description and rename the section. Cross-tuple retry on retryable error is gone — add it back via a per-call-site try/catch loop when a per-key rate-limit becomes a real problem.

* feat(admin): destructive badge variant + disabled provider card styling

New 'destructive' Badge variant (red, light + dark, mirrors the success variant's emerald pattern). Admin Providers tab swaps the Disabled badge to it and gives the entire card bg-muted/40 so a glance separates live from off — no new shade, bg-muted/40 was already in use on UsersPanel info tiles.

* refactor(providers,admin): trim speculative comments + simpler registry cast

No behavior change.
- registry: drop the through-unknown cast (ChatOpenAI IS a BaseChatModel) and rewrite the 'build all N first' rationale — keep the cost rationale, drop the speculative 'future per-tuple apply bindTools' YAGNI comment.
- admin ProviderCard: 4-line ponytail comment on a 1-line className → 2 lines (rule #5: explain why, briefly).
- badge.tsx: reformat destructive variant to multi-line, matching success's shape after a linter pass single-lined it.

* fix(providers): sort tuples + per-cacheKey counter; reword 'retryable' to 'any thrown error'

Greptile + Cubic review of PR #36:
- sort collectTuples by (providerId, modelName, keyName) — the doc
  contract was partially implemented (only the provider dimension
  was sorted via DB orderBy); models and keys came out in JSONB
  insertion order, drifting rotation across cache misses.
- replace the global nextTupleIndex counter with a per-cacheKey Map
  so different opt shapes don't bleed into each other; today all
  7 callers use the default pool (so the bleed was inert), but the
  per-agent opt direction in PROVIDERS.md would have hit it.
- reword 'On retryable error' → 'On any thrown error' in both code
  JSDoc and docs/PROVIDERS.md — withFallbacks walks on every
  thrown error (no exceptionsToHandle filter), so 'retryable' was
  inaccurate even when withFallbacks was still in play.
- export resetRoundRobinCounters() so tests start from a known
  counter baseline; wire it into beforeEach.
- dbSpy restore wrapped in try/finally so an assertion throw doesn't
  leak the spy between tests.
- rename two existing tests whose assertion (reference inequality)
  is guaranteed by per-call rebuild, not by rotation order — the
  name was misleading.

* docs(db): drop stale RunnableWithFallbacks mention in provider table notes
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant