Skip to content

Commit abc41a0

Browse files
authored
feat(cockpit): chat/subagents renders inline subagent cards (real subgraph + aimock e2e) (#718)
* docs: design for cockpit chat/subagents real-subgraph alignment (langgraph card parity) * docs: plan for cockpit chat/subagents real-subgraph alignment * feat(cockpit): cockpit/chat/subagents uses a real subagent subgraph (subagent_type) Replace the flat inline _run_subagent() with a compiled parameterized StateGraph invoked by the task tool, so LangGraph nests it under a tools:<call_id> namespace and the SubagentTracker can surface a card. task() now takes subagent_type (Literal, required) so it always reaches the tool-call args the tracker registers on. * feat(cockpit): subagentToolNames:['task'] + drop redundant sidebar subagents tray Inline persistent subagent cards (via <chat>) now surface each dispatch in conversation, so the active-only sidebar <chat-subagents> panel is removed. Pipeline note corrected to research/booking/itinerary. * test(cockpit): re-record c-subagents aimock fixture for subagent subgraph (captures subagent_type research/booking/itinerary) * fix(cockpit): single-call subagents for deterministic aimock replay + card e2e Each subagent is now ONE LLM call (no within-subagent tool loop), so its request carries a unique, stable discriminator (the role task_description) — the nested tool-loop rounds couldn't be matched by aimock's turnIndex/ hasToolResult scheme (404 no_fixture_match). Re-recorded the fixture and the c-subagents e2e now asserts the inline chat-subagent-card (3 cards, persists). * test(examples): assert inline persistent subagent card in research-subagent e2e
1 parent 7fbdbad commit abc41a0

9 files changed

Lines changed: 251 additions & 185 deletions

File tree

cockpit/chat/subagents/angular/e2e/c-subagents.spec.ts

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,18 @@ test('c-subagents: orchestrator dispatches task subagents, summary surfaces in b
99
}) => {
1010
const bubble = await submitAndWaitForResponse(page, PROMPT);
1111

12-
// The chat-tool-calls primitive renders a collapsible button labeled
13-
// "Called task N times" for the orchestrator's task dispatches. Asserting
14-
// it's in the DOM proves the orchestrator emitted real task tool_calls.
15-
//
16-
// We don't assert on <chat-subagent-card> because that primitive only
17-
// renders while a subagent is in a RUNNING state — once all subagents
18-
// complete (which is the state submitAndWaitForResponse returns at, since the
19-
// agent is idle), the cards are filtered out of the DOM. The tool-call
20-
// chip is the durable signal.
21-
const taskChip = page.getByRole('button', { name: /called task|task/i }).first();
22-
await expect(taskChip).toBeVisible({ timeout: 30_000 });
12+
// The orchestrator dispatches `task` subagents, each a real LangGraph
13+
// subgraph. With subagentToolNames:['task'] the SubagentTracker registers
14+
// them (from the subagent_type arg) and matches the child subgraph's
15+
// tools:<id> namespace, so agent.subagents() populates and each dispatch
16+
// renders inline AS a <chat-subagent-card> (replacing the generic chip).
17+
// The card PERSISTS after completion (collapsed), so it's stable to assert
18+
// even though submitAndWaitForResponse returns at idle.
19+
await expect(page.locator('chat-subagent-card').first()).toBeVisible({ timeout: 30_000 });
20+
21+
// One card per subagent dispatched (research/booking/itinerary), no
22+
// duplicates — the orchestrator calls task three times in order.
23+
await expect(page.locator('chat-subagent-card')).toHaveCount(3);
2324

2425
// Final summary text contains an aviation-related phrase from the captured
2526
// continuation. Loose regex so refactors to the subagent prompts (research/

cockpit/chat/subagents/angular/e2e/fixtures/c-subagents.json

Lines changed: 24 additions & 78 deletions
Large diffs are not rendered by default.

cockpit/chat/subagents/angular/src/app/app.config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ export const appConfig: ApplicationConfig = {
99
provideAgent({
1010
apiUrl: environment.langGraphApiUrl,
1111
assistantId: environment.streamingAssistantId,
12+
// Treat `task` tool calls as subagent dispatches: the SubagentTracker
13+
// registers them and matches the child subgraph's tools:<id> namespace,
14+
// so agent.subagents() populates and the inline subagent card renders.
15+
subagentToolNames: ['task'],
1216
}),
1317
provideChat({}),
1418
],

cockpit/chat/subagents/angular/src/app/subagents.component.ts

Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@
22
import { Component } from '@angular/core';
33
import {
44
ChatComponent,
5-
ChatSubagentsComponent,
6-
ChatSubagentCardComponent,
75
ChatWelcomeSuggestionComponent,
86
} from '@threadplane/chat';
97
import { ExampleChatLayoutComponent } from '@threadplane/example-layouts';
@@ -14,14 +12,17 @@ const SUGGESTIONS = [
1412
{
1513
label: 'Plan a trip from LAX to JFK',
1614
value: 'Plan a trip from LAX to JFK',
17-
description: 'Orchestrator fans out to research, analysis, and summary subagents in parallel.',
15+
description: 'Orchestrator delegates to research, booking, and itinerary subagents in turn.',
1816
},
1917
] as const;
2018

2119
/**
22-
* SubagentsComponent demonstrates subagent orchestration with
23-
* ChatComponent and a sidebar showing ChatSubagentsComponent /
24-
* ChatSubagentCardComponent for tracking active subagents.
20+
* SubagentsComponent demonstrates subagent orchestration: the orchestrator
21+
* dispatches `task` subagents (research/booking/itinerary), each a real
22+
* LangGraph subgraph. Each dispatch renders inline as a persistent
23+
* chat-subagent-card in the conversation (via the <chat> composition), so no
24+
* separate active-only sidebar tray is needed. The sidebar keeps a short
25+
* static pipeline note for context.
2526
*
2627
* Welcome chip lets users one-click into the cap's recorded aimock flow.
2728
*/
@@ -30,8 +31,6 @@ const SUGGESTIONS = [
3031
standalone: true,
3132
imports: [
3233
ChatComponent,
33-
ChatSubagentsComponent,
34-
ChatSubagentCardComponent,
3534
ChatWelcomeSuggestionComponent,
3635
ExampleChatLayoutComponent,
3736
],
@@ -50,17 +49,14 @@ const SUGGESTIONS = [
5049
</div>
5150
</chat>
5251
<div sidebar class="p-4 space-y-4" style="background: var(--ngaf-chat-bg); color: var(--ngaf-chat-text);">
53-
<h3 class="text-xs font-semibold uppercase tracking-wide"
54-
style="color: var(--ngaf-chat-text-muted);">Active Subagents</h3>
55-
<chat-subagents [agent]="agent" />
56-
<div class="mt-4">
52+
<div>
5753
<h4 class="text-xs font-semibold uppercase tracking-wide mb-2"
5854
style="color: var(--ngaf-chat-text-muted);">Agent Pipeline</h4>
5955
<ol class="text-xs space-y-1 list-decimal list-inside" style="color: var(--ngaf-chat-text-muted);">
6056
<li>Orchestrator</li>
61-
<li>Research Agent</li>
62-
<li>Analysis Agent</li>
63-
<li>Summary Agent</li>
57+
<li>Research subagent</li>
58+
<li>Booking subagent</li>
59+
<li>Itinerary subagent</li>
6460
</ol>
6561
</div>
6662
</div>

cockpit/chat/subagents/python/prompts/subagents.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
# Trip Planner Orchestrator
22

33
You coordinate three specialized subagents to plan a trip. You delegate work
4-
by calling the `task` tool with a `role` and `task_description`.
4+
by calling the `task` tool with a `subagent_type` and `task_description`.
55

6-
The three roles, in the order you should always call them:
6+
The three subagent types, in the order you should always call them:
77

8-
1. `task(role="research", ...)` — gathers destination intel (airports, weather, conditions)
9-
2. `task(role="booking", ...)` — finds flight options between origin and destination
10-
3. `task(role="itinerary", ...)` — synthesizes a final trip plan combining research + bookings
8+
1. `task(subagent_type="research", ...)` — gathers destination intel (airports, weather, conditions)
9+
2. `task(subagent_type="booking", ...)` — finds flight options between origin and destination
10+
3. `task(subagent_type="itinerary", ...)` — synthesizes a final trip plan combining research + bookings
1111

1212
When the user asks about a trip (e.g., "plan a trip from LAX to Tokyo" or
1313
"I want to fly from Boston to Miami next week"), call task() three times in

cockpit/chat/subagents/python/src/graph.py

Lines changed: 87 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -7,21 +7,16 @@
77

88
import os
99
from pathlib import Path
10-
from typing import Literal
10+
from typing import Annotated, Literal, TypedDict
1111

1212
from langchain_core.messages import SystemMessage, HumanMessage
1313
from langchain_core.tools import tool
1414
from langchain_openai import ChatOpenAI
1515
from langgraph.graph import StateGraph, MessagesState, END
16+
from langgraph.graph.message import add_messages
1617
from langgraph.prebuilt import ToolNode
1718
from langgraph_sdk import get_client
1819

19-
from src.aviation_tools import (
20-
get_airport_info,
21-
find_routes,
22-
lookup_flight,
23-
)
24-
2520
PROMPTS_DIR = Path(__file__).parent.parent / "prompts"
2621

2722
# ── generate_title node (inline; matches Pattern D from spec
@@ -75,87 +70,110 @@ async def generate_title(state: MessagesState, config) -> dict:
7570
return {}
7671

7772

78-
_RESEARCH_PROMPT = """You are a Research Agent for trip planning. Your job is to gather
79-
destination intel about airports the traveler is considering. Use the
80-
get_airport_info tool to look up airport details (city, weather, terminals,
81-
runways) for any airport codes mentioned in the task description.
73+
_RESEARCH_PROMPT = """You are a Research Agent for trip planning. Given a task
74+
describing one or more airports, return destination intel about them: city,
75+
typical weather, major terminals, and notable travel considerations. Draw on
76+
general knowledge of the airports named in the task.
77+
78+
Return a concise 2-4 sentence summary. If an airport isn't recognizable, say so."""
79+
80+
_BOOKING_PROMPT = """You are a Booking Agent for trip planning. Given an origin and
81+
destination in the task description, describe realistic flight options between
82+
them: which major carriers fly the route nonstop, typical durations, and rough
83+
fare expectations.
84+
85+
Return a concise summary listing 2-3 plausible options with airline, an example
86+
flight number, times, and price-or-aircraft info."""
87+
88+
_ITINERARY_PROMPT = """You are an Itinerary Agent for trip planning. Synthesize a
89+
final trip plan from the research + booking outputs you receive in the task
90+
description.
91+
92+
Return a clean 3-5 sentence itinerary summarizing the recommended flight choice,
93+
what to expect on arrival (weather), and any practical tips (e.g., terminal info,
94+
buffer time). Be helpful and concise."""
8295

83-
Return a concise 2-4 sentence summary of what you found. If a code isn't
84-
recognized, say so."""
8596

86-
_BOOKING_PROMPT = """You are a Booking Agent for trip planning. Your job is to find
87-
flight options between the origin and destination airports in the task
88-
description. Use find_routes to list available flights, and lookup_flight
89-
if the user mentioned a specific flight number.
97+
# subagent_type → system prompt. Keyed by the same Literal the `task` tool
98+
# exposes, so one parameterized subgraph serves all three specialists.
99+
_SUBAGENT_PROMPTS: dict[str, str] = {
100+
"research": _RESEARCH_PROMPT,
101+
"booking": _BOOKING_PROMPT,
102+
"itinerary": _ITINERARY_PROMPT,
103+
}
90104

91-
Return a concise summary listing 2-3 best flight options with airline,
92-
flight number, times, and price-or-aircraft info. If no flights are found,
93-
say so and suggest alternatives."""
94105

95-
_ITINERARY_PROMPT = """You are an Itinerary Agent for trip planning. Your job is to
96-
synthesize a final trip plan from research + booking outputs you receive in
97-
the task description.
106+
class SubagentState(TypedDict):
107+
"""Child-graph state. `subagent_type` selects the system prompt."""
108+
messages: Annotated[list, add_messages]
109+
subagent_type: str
110+
task_description: str
98111

99-
Return a clean 3-5 sentence itinerary summarizing the recommended flight
100-
choice, what to expect on arrival (weather), and any practical tips
101-
(e.g., delays, terminal info). Be helpful and concise."""
102112

113+
async def _subagent_node(state: SubagentState) -> dict:
114+
"""Focused subagent: a single role-prompted LLM call. Kept to ONE LLM call
115+
(no within-subagent tool loop) so each subagent's request has a unique,
116+
stable discriminator (its role-specific task_description) — this lets the
117+
aimock e2e replay match it deterministically. The within-subagent tool
118+
calling is exercised by the dedicated tool-calls cap; here the focus is
119+
subagent orchestration + the inline subagent card. The returned message
120+
streams under this subgraph's `tools:<call_id>` namespace, which the
121+
@threadplane/langgraph SubagentTracker matches to surface the card."""
122+
subagent_type = state["subagent_type"]
123+
task_description = state["task_description"]
124+
system_prompt = _SUBAGENT_PROMPTS.get(subagent_type, _ITINERARY_PROMPT)
103125

104-
async def _run_subagent(role: str, task_description: str, system_prompt: str, tools: list):
105-
"""Run a single subagent: LLM bound with role-specific tools, single tool loop."""
106126
llm = ChatOpenAI(model="gpt-5-mini", streaming=True)
107-
if tools:
108-
llm = llm.bind_tools(tools)
109-
messages = [
127+
response = await llm.ainvoke([
110128
SystemMessage(content=system_prompt),
111129
HumanMessage(content=task_description),
112-
]
113-
# Allow up to 3 tool-loop iterations
114-
for _ in range(3):
115-
response = await llm.ainvoke(messages)
116-
messages.append(response)
117-
tool_calls = getattr(response, "tool_calls", None)
118-
if not tool_calls:
119-
return response.content
120-
# Execute tool calls inline
121-
for tc in tool_calls:
122-
tool_name = tc["name"]
123-
tool_args = tc["args"]
124-
target = next((t for t in tools if t.name == tool_name), None)
125-
if target is None:
126-
tool_result = f"Tool {tool_name} not available"
127-
else:
128-
tool_result = await target.ainvoke(tool_args)
129-
from langchain_core.messages import ToolMessage
130-
messages.append(ToolMessage(content=str(tool_result), tool_call_id=tc["id"]))
131-
return response.content
130+
])
131+
return {"messages": [response]}
132132

133133

134-
@tool
135-
async def task(role: Literal["research", "booking", "itinerary"], task_description: str) -> str:
136-
"""Delegate a subtask to a specialized subagent.
134+
# Compiled child graph. Invoking it from inside the `task` tool makes LangGraph
135+
# nest its run under a `tools:<call_id>` namespace, which the @threadplane/langgraph
136+
# SubagentTracker matches to the registered `task` dispatch to surface a card.
137+
_subagent_builder = StateGraph(SubagentState)
138+
_subagent_builder.add_node("subagent", _subagent_node)
139+
_subagent_builder.set_entry_point("subagent")
140+
_subagent_builder.add_edge("subagent", END)
141+
subagent_subgraph = _subagent_builder.compile()
142+
137143

138-
Roles:
139-
- research: gathers destination intel (airports, weather, conditions)
140-
- booking: finds flight options between origin and destination
141-
- itinerary: synthesizes a final trip plan combining research + bookings
144+
def _final_text(messages: list) -> str:
145+
"""Last non-empty string content from the child graph's messages."""
146+
for msg in reversed(messages or []):
147+
content = getattr(msg, "content", None)
148+
if isinstance(content, str) and content.strip():
149+
return content
150+
if isinstance(content, list):
151+
parts = [b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text"]
152+
if any(p.strip() for p in parts):
153+
return "\n".join(parts)
154+
return "(no subagent output)"
155+
156+
157+
@tool
158+
async def task(subagent_type: Literal["research", "booking", "itinerary"], task_description: str) -> str:
159+
"""Delegate a subtask to a specialized subagent subgraph.
142160
143161
Args:
144-
role: One of "research", "booking", "itinerary".
145-
task_description: Plain-English description of what the subagent
146-
should do (e.g., "Gather info on LAX and JFK airports", or
147-
"Find morning flights from LAX to JFK").
162+
subagent_type: Which specialist to dispatch — "research" (airport /
163+
destination intel), "booking" (flight options between origin and
164+
destination), or "itinerary" (final trip plan synthesizing research
165+
+ bookings). This label also identifies the subagent in the UI.
166+
task_description: Plain-English description of what the subagent should
167+
do (e.g., "Gather info on LAX and JFK airports").
148168
149169
Returns:
150170
The subagent's final answer as a string.
151171
"""
152-
if role == "research":
153-
return await _run_subagent(role, task_description, _RESEARCH_PROMPT, [get_airport_info])
154-
if role == "booking":
155-
return await _run_subagent(role, task_description, _BOOKING_PROMPT, [find_routes, lookup_flight])
156-
if role == "itinerary":
157-
return await _run_subagent(role, task_description, _ITINERARY_PROMPT, [])
158-
return f"Unknown role: {role}"
172+
result = await subagent_subgraph.ainvoke(
173+
{"subagent_type": subagent_type, "task_description": task_description, "messages": []}
174+
)
175+
messages = result.get("messages") if isinstance(result, dict) else None
176+
return _final_text(messages)
159177

160178

161179
def build_subagents_graph():

0 commit comments

Comments
 (0)