mirror of
https://github.com/CopilotKit/CopilotKit.git
synced 2026-09-14 16:26:20 +08:00
e4eb96f8d7
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
278 lines
10 KiB
Python
278 lines
10 KiB
Python
"""Deterministic A2UI op-builder + ``render_report`` tool for the banking canvas.
|
|
|
|
Python port of ``src/skins/banking/build-report-ops.ts`` and of the
|
|
``render_report`` ``defineTool`` in ``src/skins/banking/agent.ts``. This module is
|
|
intentionally standalone — it imports nothing else from ``agent/`` — so it can be
|
|
dropped into any LangGraph/LangChain agent that wants the banking report canvas.
|
|
|
|
What an A2UI operation is
|
|
-------------------------
|
|
A2UI (v0.9) describes a *surface*: a server-declared UI region the client renders
|
|
from a catalog of components it already ships. An operation list is a small
|
|
sequence of envelopes, each carrying a ``version`` plus exactly one operation
|
|
body:
|
|
|
|
* ``createSurface`` — open surface ``surfaceId``, rendered against ``catalogId``.
|
|
* ``updateComponents`` — replace the surface's flat component list. Components are
|
|
a flat array of ``{id, component, ...props}`` records wired together by id: the
|
|
root is always ``id: "root"`` and every container names its ``children`` by id.
|
|
|
|
No data travels in the operations. ``StatCard`` / ``Chart`` / ``Transactions``
|
|
bind LIVE client data through ``useReportData()`` in the catalog renderers, so the
|
|
agent supplies only metric/kind selections and label-only text. That is why the
|
|
title/summary descriptions below forbid figures: a number in a label would be the
|
|
model's guess sitting next to real ledger data.
|
|
|
|
Why the builder is deterministic
|
|
--------------------------------
|
|
The reasoning model picks only WHAT to show — a title plus which KPIs and charts —
|
|
and this module expands that tiny selection into the verbose component JSON. The
|
|
model must NEVER author the component JSON itself: hand-written A2UI from an LLM
|
|
is slow to stream, easy to get subtly wrong (bad ids, unknown components, dangling
|
|
children), and unreviewable. Keeping the expansion in code is what makes the
|
|
canvas fast and reliable, and it means the only thing that can be wrong is the
|
|
selection.
|
|
|
|
The ``a2ui_operations`` contract with the middleware
|
|
---------------------------------------------------
|
|
CopilotKit's A2UI middleware watches the AG-UI event stream. On a
|
|
``TOOL_CALL_RESULT`` it parses the result content and looks for the key
|
|
``a2ui_operations``; if it finds an array there it converts it into an
|
|
``a2ui-surface`` activity, which the shared canvas hands to the banking skin's
|
|
``CanvasSurface``. The middleware inspects EVENTS only and does not care which
|
|
process produced them, so a remote Python agent works identically to the built-in
|
|
TypeScript one — provided the tool result is structurally identical: same key,
|
|
same operation envelopes, same ``version`` fields, same surfaceId scheme.
|
|
|
|
``render_report`` therefore returns a plain ``dict``. LangGraph's tool node
|
|
JSON-encodes a non-string tool return into the ``ToolMessage`` content, so the
|
|
key survives into the ``TOOL_CALL_RESULT`` payload the middleware parses.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
from typing import Any, Literal, Optional, get_args
|
|
|
|
from langchain.tools import tool
|
|
from pydantic import BaseModel, Field
|
|
|
|
# Must match the middleware's A2UI_OPERATIONS_KEY so its result parser detects it.
|
|
A2UI_OPERATIONS_KEY = "a2ui_operations"
|
|
|
|
SURFACE_ID = "spend-report"
|
|
|
|
# Mirrors CATALOG_ID in src/skins/banking/catalog/definitions.ts. Kept as a
|
|
# literal because this module must not import TypeScript; if the catalog id ever
|
|
# changes there, change it here too or the client renders an unknown catalog.
|
|
CATALOG_ID = "https://cpk-a2ui.local/catalogs/banking/v1"
|
|
|
|
A2UI_VERSION = "v0.9"
|
|
|
|
ReportMetric = Literal[
|
|
"totalSpend",
|
|
"pendingCount",
|
|
"overLimitCount",
|
|
"policyCount",
|
|
]
|
|
REPORT_METRICS: tuple[str, ...] = get_args(ReportMetric)
|
|
|
|
ReportChart = Literal[
|
|
"spendingTrend",
|
|
"budgetUsage",
|
|
"spendBreakdown",
|
|
"incomeVsExpenses",
|
|
]
|
|
REPORT_CHARTS: tuple[str, ...] = get_args(ReportChart)
|
|
|
|
ReportTxStatus = Literal["all", "pending", "approved", "denied"]
|
|
REPORT_TX_STATUSES: tuple[str, ...] = get_args(ReportTxStatus)
|
|
|
|
# Human captions for each KPI — assigned here so the agent needn't supply them.
|
|
METRIC_LABELS: dict[str, str] = {
|
|
"totalSpend": "Total approved spend",
|
|
"pendingCount": "Pending approvals",
|
|
"overLimitCount": "Over limit",
|
|
"policyCount": "Expense policies",
|
|
}
|
|
|
|
|
|
class RenderReportSpec(BaseModel):
|
|
"""Parameters for the render_report tool (kept intentionally small)."""
|
|
|
|
title: str = Field(
|
|
description=(
|
|
"Short report title, e.g. 'Q2 Spend Report'. LABEL ONLY — no figures, "
|
|
"amounts, percentages, or trend claims."
|
|
),
|
|
)
|
|
kpis: list[ReportMetric] = Field(
|
|
description=(
|
|
"Which KPI stat cards to show, in order. Pick those relevant to the "
|
|
"question."
|
|
),
|
|
)
|
|
charts: list[ReportChart] = Field(
|
|
description="Which charts to show, in order.",
|
|
)
|
|
transactions: Optional[ReportTxStatus] = Field(
|
|
default=None,
|
|
description=(
|
|
"Include a live transactions table filtered by status: 'all', "
|
|
"'pending', 'approved', or 'denied'. Omit to leave it out."
|
|
),
|
|
)
|
|
summary: Optional[str] = Field(
|
|
default=None,
|
|
description=(
|
|
"Optional one-line NEUTRAL caption under the title. Label-only — no "
|
|
"figures, amounts, percentages, or trends."
|
|
),
|
|
)
|
|
|
|
|
|
def new_surface_id(base: str = SURFACE_ID) -> str:
|
|
"""Mint a unique surfaceId so a dismissed report never suppresses a later one.
|
|
|
|
The canvas remembers the surfaceId the user dismissed, so every report needs
|
|
its own. The suffix is a uuid4 fragment rather than a timestamp (two reports
|
|
in the same millisecond would collide, and a clock makes output
|
|
non-reproducible in tests and recorded fixtures) and rather than a
|
|
process-local counter (which restarts at 1 on every reload and runs
|
|
independently in each worker process, so it can re-issue an id the browser
|
|
already has marked dismissed — the exact bug the unique suffix exists to
|
|
prevent). uuid4 is unique across processes, restarts and workers.
|
|
"""
|
|
return f"{base}-{uuid.uuid4().hex[:8]}"
|
|
|
|
|
|
def build_report_ops(
|
|
spec: RenderReportSpec,
|
|
surface_id: str = SURFACE_ID,
|
|
) -> list[dict[str, Any]]:
|
|
"""Expand a report selection into A2UI v0.9 operations.
|
|
|
|
Returns ``createSurface`` + ``updateComponents`` (flat components, root id
|
|
"root"), structurally identical to the TypeScript ``buildReportOps``.
|
|
"""
|
|
components: list[dict[str, Any]] = []
|
|
root_children: list[str] = []
|
|
|
|
components.append({"id": "heading", "component": "Heading", "text": spec.title})
|
|
root_children.append("heading")
|
|
|
|
if spec.summary:
|
|
components.append(
|
|
{
|
|
"id": "summary",
|
|
"component": "Text",
|
|
"text": spec.summary,
|
|
"tone": "muted",
|
|
}
|
|
)
|
|
root_children.append("summary")
|
|
|
|
if spec.kpis:
|
|
kpi_ids: list[str] = []
|
|
for metric in spec.kpis:
|
|
component_id = f"kpi-{metric}"
|
|
components.append(
|
|
{
|
|
"id": component_id,
|
|
"component": "StatCard",
|
|
"metric": metric,
|
|
"label": METRIC_LABELS[metric],
|
|
}
|
|
)
|
|
kpi_ids.append(component_id)
|
|
components.append(
|
|
{
|
|
"id": "kpi-grid",
|
|
"component": "Grid",
|
|
"columns": min(len(spec.kpis), 4),
|
|
"children": kpi_ids,
|
|
}
|
|
)
|
|
root_children.append("kpi-grid")
|
|
|
|
if spec.charts:
|
|
chart_ids: list[str] = []
|
|
for kind in spec.charts:
|
|
component_id = f"chart-{kind}"
|
|
components.append({"id": component_id, "component": "Chart", "kind": kind})
|
|
chart_ids.append(component_id)
|
|
components.append(
|
|
{
|
|
"id": "chart-grid",
|
|
"component": "Grid",
|
|
"columns": 2 if len(spec.charts) >= 2 else 1,
|
|
"children": chart_ids,
|
|
}
|
|
)
|
|
root_children.append("chart-grid")
|
|
|
|
if spec.transactions:
|
|
components.append(
|
|
{
|
|
"id": "transactions",
|
|
"component": "Transactions",
|
|
"status": spec.transactions,
|
|
}
|
|
)
|
|
root_children.append("transactions")
|
|
|
|
components.insert(
|
|
0,
|
|
{
|
|
"id": "root",
|
|
"component": "Stack",
|
|
"gap": "lg",
|
|
"children": root_children,
|
|
},
|
|
)
|
|
|
|
return [
|
|
{
|
|
"version": A2UI_VERSION,
|
|
"createSurface": {"surfaceId": surface_id, "catalogId": CATALOG_ID},
|
|
},
|
|
{
|
|
"version": A2UI_VERSION,
|
|
"updateComponents": {"surfaceId": surface_id, "components": components},
|
|
},
|
|
]
|
|
|
|
|
|
def extract_surface_id(ops: list[dict[str, Any]]) -> Optional[str]:
|
|
"""Read the surfaceId out of an A2UI operation list (any op kind)."""
|
|
for op in ops:
|
|
target = (
|
|
op.get("createSurface")
|
|
or op.get("updateComponents")
|
|
or op.get("updateDataModel")
|
|
)
|
|
if isinstance(target, dict) and target.get("surfaceId"):
|
|
return target["surfaceId"]
|
|
return None
|
|
|
|
|
|
@tool("render_report", args_schema=RenderReportSpec)
|
|
def render_report(
|
|
title: str,
|
|
kpis: list[str],
|
|
charts: list[str],
|
|
transactions: Optional[str] = None,
|
|
summary: Optional[str] = None,
|
|
) -> dict[str, Any]:
|
|
"""Render a multi-widget spend report on the CANVAS (the app's main content area, outside the chat). Choose which KPIs and charts to include; the client renders live banking figures — you never pass numbers. Use for a report/overview/dashboard/analysis request or 'show it on the canvas', NOT for a single inline chart."""
|
|
spec = RenderReportSpec(
|
|
title=title,
|
|
kpis=kpis,
|
|
charts=charts,
|
|
transactions=transactions,
|
|
summary=summary,
|
|
)
|
|
# Unique surfaceId per report so dismissing one report never suppresses a
|
|
# later one (the canvas tracks the dismissed surfaceId).
|
|
return {A2UI_OPERATIONS_KEY: build_report_ops(spec, new_surface_id())}
|