Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
246 changes: 246 additions & 0 deletions cockpit/runtimes/mastra/angular/docs/wire-capture-subagents.md

Large diffs are not rendered by default.

31 changes: 31 additions & 0 deletions cockpit/runtimes/mastra/angular/e2e/fixtures/mastra.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,18 @@
"content": "North Pines is reserved for 2 nights — confirmation TP-0288."
}
},
{
"match": { "userMessage": "Bear Lake", "hasToolResult": true },
"response": {
"content": "Expect a sunny, mild weekend at Bear Lake — great weather for camping."
}
},
{
"match": { "systemMessage": "You are a weather forecaster" },
"response": {
"content": "Here's the Bear Lake weekend forecast:\n\n- Saturday: sunny, high 24°C\n- Sunday: partly cloudy, high 22°C\n- Overall: mild with light winds"
}
},
{
"match": { "userMessage": "packing list" },
"response": {
Expand Down Expand Up @@ -60,6 +72,25 @@
}
]
}
},
{
"match": { "userMessage": "Bear Lake" },
"response": {
"toolCalls": [
{
"name": "agent-weather_forecaster",
"arguments": {
"prompt": "What will the weather be like at Bear Lake this weekend?",
"threadId": null,
"resourceId": null,
"instructions": null,
"maxSteps": 5,
"suspendedToolRunId": null,
"resumeData": null
}
}
]
}
}
]
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
13 changes: 13 additions & 0 deletions cockpit/runtimes/mastra/angular/e2e/mastra.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: MIT
import { test, expect } from '@playwright/test';
import { submitAndWaitForResponse } from '@threadplane-internal/e2e-harness';

// First cockpit e2e whose backend is neither LangGraph nor Python: the
// rt-mastra topic runs against the deployments/ag-ui-mastra Node service
Expand Down Expand Up @@ -43,4 +44,16 @@ test.describe('cockpit runtimes/mastra: camping trip planner', () => {
await dialog.getByRole('button', { name: 'Approve' }).click();
await expect(page.getByText(/reserved for 2 nights/i)).toBeVisible({ timeout: 30_000 });
});

// Delegation: the supervisor calls the registered weather_forecaster
// sub-agent (wire tool `agent-weather_forecaster`); the server-side
// emitter (deployments/ag-ui-mastra/subagent-emitter.mjs) injects
// SUBAGENT_STARTED + attributed TEXT_MESSAGE_* + SUBAGENT_FINISHED, which
// the adapter reduces into a subagent card on the tool-call group.
test('rt-mastra: delegated forecast renders a subagent card with the final text', async ({ page }) => {
const bubble = await submitAndWaitForResponse(page, 'Plan a trip to Bear Lake this weekend — what will the weather be?');
await expect(page.locator('chat-subagent-card')).toHaveCount(1);
await expect(page.locator('chat-subagent-card')).toContainText('weather_forecaster');
await expect(bubble).toContainText(/forecast|weather/i);
});
});
7 changes: 5 additions & 2 deletions cockpit/runtimes/mastra/angular/src/app/mastra.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,11 @@ interface PackingList {
* `submit({ resume })` goes out as
* `forwardedProps.command = { resume, interruptEvent: { toolCallId, runId } }`
* — exactly what the Mastra bridge requires to resume the suspended run.
* - NO subagents surface here: Mastra reserves ACTIVITY_* for background
* tasks, a measured red cell in the matrix.
* - Sub-agent delegation (weather_forecaster) reaches the wire as a
* `agent-<key>` tool call; the Node service's subagent emitter turns it
* into SUBAGENT_* + attributed TEXT_MESSAGE_* frames, so the standard
* chat-subagent-card renders with zero code in this component
* (docs/wire-capture-subagents.md).
*/
@Component({
selector: 'app-mastra',
Expand Down
20 changes: 19 additions & 1 deletion deployments/ag-ui-mastra/agents.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -96,19 +96,37 @@ const reserveCampsiteTool = createTool({
export function createMastra(dbUrl) {
const store = (id) => new LibSQLStore({ id, url: dbUrl });

/**
* Sub-agent (spike: wire-capture-subagents.md). Registered on the
* supervisor via `agents:`; Mastra surfaces it as a backend tool named
* `agent-weather_forecaster` whose TOOL_CALL_RESULT carries the child's
* final text — server.mjs's subagent emitter turns that into SUBAGENT_*
* frames. The `description` becomes the delegation tool's description.
*/
const weatherForecaster = new Agent({
id: 'weather_forecaster',
name: 'weather_forecaster',
description: 'Forecasts weather for a campsite and date range. Use for any weather question.',
instructions:
'You are a weather forecaster. Given a campsite and dates, give a 3-bullet forecast summary. Be concise.',
model: MODEL,
});

const tripAgent = new Agent({
id: 'mastra',
name: 'mastra',
instructions: `You are a terse camping trip planner.
The packing list in working memory is the user's shared state: whenever the user adds, removes, or changes items (or starts a list), update working memory to match. 'items' is an array of {name, qty}. Never mention memory or the list mechanics.
For questions about weather or trail conditions you MUST call check_conditions.
For questions about trail conditions you MUST call check_conditions.
For questions about weather forecasts you MUST delegate to the weather_forecaster agent.
When the user asks to reserve or book a campsite you MUST call reserve_campsite; after it resumes, confirm the outcome.
Always answer in one short sentence.`,
model: MODEL,
tools: {
check_conditions: checkConditionsTool,
reserve_campsite: reserveCampsiteTool,
},
agents: { weather_forecaster: weatherForecaster },
memory: new Memory({
storage: store('mastra-topic-memory'),
options: {
Expand Down
11 changes: 7 additions & 4 deletions deployments/ag-ui-mastra/server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { pathToFileURL } from 'node:url';
import { dirname, resolve } from 'node:path';
import { MastraAgent } from '@ag-ui/mastra';
import { createMastra } from './agents.mjs';
import { createSubagentInjector } from './subagent-emitter.mjs';

const AG_UI_INTERNAL_TOKEN = process.env.AG_UI_INTERNAL_TOKEN;
if (!AG_UI_INTERNAL_TOKEN) {
Expand Down Expand Up @@ -108,16 +109,18 @@ export function createAgUiServer() {
resourceId: input.threadId,
});

// One injector per run: turns delegation tool calls (`agent-<childKey>`)
// into SUBAGENT_* frames around the events the bridge already emits.
const injector = createSubagentInjector();
const sub = bridge.run(input).subscribe({
next: (event) => {
res.write(sseFrame(event));
for (const e of injector.eventsFor(event)) res.write(sseFrame(e));
},
error: (err) => {
// Map failures into the protocol instead of killing the socket:
// the client finalizes the run as an error rather than hanging.
res.write(
sseFrame({ type: 'RUN_ERROR', message: String(err?.message ?? err) }),
);
const runError = { type: 'RUN_ERROR', message: String(err?.message ?? err) };
for (const e of injector.eventsFor(runError)) res.write(sseFrame(e));
res.end();
},
complete: () => {
Expand Down
125 changes: 125 additions & 0 deletions deployments/ag-ui-mastra/subagent-emitter.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
// SPDX-License-Identifier: MIT
// SUBAGENT_* injection for Mastra delegation tool calls.
//
// Mastra surfaces a registered sub-agent as an ordinary backend tool named
// `agent-<childKey>`: TOOL_CALL_START → TOOL_CALL_ARGS → TOOL_CALL_END →
// TOOL_CALL_RESULT whose `content` is JSON `{text, subAgentThreadId, ...}`
// (measured: cockpit/runtimes/mastra/angular/docs/wire-capture-subagents.md).
// The upstream @ag-ui/mastra bridge drops the in-process child deltas
// (`case "tool-output": break`), so the honest wire contract here is a
// single final text chunk per delegation.
//
// This module is a pure transform over the outbound AG-UI event stream —
// keyed off the events themselves, NOT the Mastra delegation hooks, which
// fire in a different async context with no ordering guarantee relative to
// the Observable frames.
//
// Injected sequence per delegation tool call <tid> (child key = tool name
// minus the `agent-` prefix, subagentRunId = `<tid>-sub`):
// - AFTER TOOL_CALL_START: SUBAGENT_STARTED {subagentRunId, name,
// parentToolCallId}
// - BEFORE TOOL_CALL_RESULT (success): TEXT_MESSAGE_START/CONTENT/END
// carrying the child's final text under the subagent identity, then
// SUBAGENT_FINISHED {outcome:{type:'success'}}
// - BEFORE TOOL_CALL_RESULT (failure — parsed content says success:false
// or finishReason:'error'): SUBAGENT_ERROR {subagentRunId, message}
// - Terminal cleanup: a RUN_ERROR or RUN_FINISHED arriving while
// delegations are still pending (no TOOL_CALL_RESULT seen — e.g. the
// Observable errored mid-delegation) closes each pending card with
// SUBAGENT_ERROR before the terminal frame, so no card is left spinning.
// In the measured captures the RESULT always precedes the terminal frame,
// so this path is defensive only.

const AGENT_TOOL_PREFIX = 'agent-';

/**
* Create a per-run injector.
*
* @returns {{ eventsFor(event: object): object[] }} — for each outbound
* AG-UI event, the ordered list of frames to write (injections plus the
* original event). Non-delegation events pass through as `[event]`.
*/
export function createSubagentInjector() {
/** @type {Map<string, {subagentRunId: string, name: string}>} pending delegations by toolCallId */
const pending = new Map();

return {
eventsFor(event) {
switch (event.type) {
case 'TOOL_CALL_START': {
const name = event.toolCallName ?? '';
if (!name.startsWith(AGENT_TOOL_PREFIX)) return [event];
const entry = {
subagentRunId: `${event.toolCallId}-sub`,
name: name.slice(AGENT_TOOL_PREFIX.length),
};
pending.set(event.toolCallId, entry);
return [
event,
{
type: 'SUBAGENT_STARTED',
subagentRunId: entry.subagentRunId,
name: entry.name,
parentToolCallId: event.toolCallId,
},
];
}

case 'TOOL_CALL_RESULT': {
const entry = pending.get(event.toolCallId);
if (!entry) return [event]; // not a delegation (or unmatched) — pass through
pending.delete(event.toolCallId);
const { subagentRunId } = entry;

const raw = typeof event.content === 'string' ? event.content : JSON.stringify(event.content);
let parsed;
try {
parsed = JSON.parse(raw);
} catch {
parsed = undefined;
}
const failed =
parsed !== undefined &&
typeof parsed === 'object' &&
parsed !== null &&
(parsed.success === false || parsed.finishReason === 'error');
if (failed) {
return [
{
type: 'SUBAGENT_ERROR',
subagentRunId,
message: String(parsed.error ?? parsed.text ?? 'sub-agent delegation failed'),
},
event,
];
}

const text = typeof parsed?.text === 'string' ? parsed.text : raw;
const messageId = `${event.toolCallId}-sub-m1`;
return [
{ type: 'TEXT_MESSAGE_START', messageId, role: 'assistant', subagentRunId },
{ type: 'TEXT_MESSAGE_CONTENT', messageId, delta: text, subagentRunId },
{ type: 'TEXT_MESSAGE_END', messageId, subagentRunId },
{ type: 'SUBAGENT_FINISHED', subagentRunId, outcome: { type: 'success' } },
event,
];
}

case 'RUN_ERROR':
case 'RUN_FINISHED': {
if (pending.size === 0) return [event];
const cleanup = [...pending.values()].map(({ subagentRunId }) => ({
type: 'SUBAGENT_ERROR',
subagentRunId,
message: 'delegation did not complete before the run terminated',
}));
pending.clear();
return [...cleanup, event];
}

default:
return [event];
}
},
};
}
Loading
Loading