@townco/agent 0.1.56 → 0.1.57
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/acp-server/adapter.d.ts +6 -0
- package/dist/acp-server/adapter.js +88 -18
- package/dist/acp-server/http.js +11 -1
- package/dist/definition/index.d.ts +7 -0
- package/dist/definition/index.js +7 -0
- package/dist/runner/agent-runner.d.ts +3 -0
- package/dist/runner/agent-runner.js +2 -1
- package/dist/runner/langchain/index.js +121 -1
- package/dist/runner/langchain/tools/subagent-connections.d.ts +28 -0
- package/dist/runner/langchain/tools/subagent-connections.js +58 -0
- package/dist/runner/langchain/tools/subagent.js +5 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +6 -6
|
@@ -25,6 +25,7 @@ export declare class AgentAcpAdapter implements acp.Agent {
|
|
|
25
25
|
private agentDescription;
|
|
26
26
|
private agentSuggestedPrompts;
|
|
27
27
|
private agentInitialMessage;
|
|
28
|
+
private agentUiConfig;
|
|
28
29
|
private currentToolOverheadTokens;
|
|
29
30
|
private currentMcpOverheadTokens;
|
|
30
31
|
constructor(agent: AgentRunner, connection: acp.AgentSideConnection, agentDir?: string, agentName?: string);
|
|
@@ -49,6 +50,11 @@ export declare class AgentAcpAdapter implements acp.Agent {
|
|
|
49
50
|
private saveSessionToDisk;
|
|
50
51
|
initialize(_params: acp.InitializeRequest): Promise<acp.InitializeResponse>;
|
|
51
52
|
newSession(params: acp.NewSessionRequest): Promise<acp.NewSessionResponse>;
|
|
53
|
+
/**
|
|
54
|
+
* Store an initial message in the session.
|
|
55
|
+
* Called by the HTTP transport after sending the initial message via SSE.
|
|
56
|
+
*/
|
|
57
|
+
storeInitialMessage(sessionId: string, content: string): Promise<void>;
|
|
52
58
|
loadSession(params: acp.LoadSessionRequest): Promise<acp.LoadSessionResponse>;
|
|
53
59
|
authenticate(_params: acp.AuthenticateRequest): Promise<acp.AuthenticateResponse | undefined>;
|
|
54
60
|
setSessionMode(_params: acp.SetSessionModeRequest): Promise<acp.SetSessionModeResponse>;
|
|
@@ -106,6 +106,7 @@ export class AgentAcpAdapter {
|
|
|
106
106
|
agentDescription;
|
|
107
107
|
agentSuggestedPrompts;
|
|
108
108
|
agentInitialMessage;
|
|
109
|
+
agentUiConfig;
|
|
109
110
|
currentToolOverheadTokens = 0; // Track tool overhead for current turn
|
|
110
111
|
currentMcpOverheadTokens = 0; // Track MCP overhead for current turn
|
|
111
112
|
constructor(agent, connection, agentDir, agentName) {
|
|
@@ -119,6 +120,7 @@ export class AgentAcpAdapter {
|
|
|
119
120
|
this.agentDescription = agent.definition.description;
|
|
120
121
|
this.agentSuggestedPrompts = agent.definition.suggestedPrompts;
|
|
121
122
|
this.agentInitialMessage = agent.definition.initialMessage;
|
|
123
|
+
this.agentUiConfig = agent.definition.uiConfig;
|
|
122
124
|
this.noSession = process.env.TOWN_NO_SESSION === "true";
|
|
123
125
|
this.storage =
|
|
124
126
|
agentDir && agentName && !this.noSession
|
|
@@ -265,6 +267,7 @@ export class AgentAcpAdapter {
|
|
|
265
267
|
...(this.agentInitialMessage
|
|
266
268
|
? { initialMessage: this.agentInitialMessage }
|
|
267
269
|
: {}),
|
|
270
|
+
...(this.agentUiConfig ? { uiConfig: this.agentUiConfig } : {}),
|
|
268
271
|
...(toolsMetadata.length > 0 ? { tools: toolsMetadata } : {}),
|
|
269
272
|
...(mcpsMetadata.length > 0 ? { mcps: mcpsMetadata } : {}),
|
|
270
273
|
...(subagentsMetadata.length > 0 ? { subagents: subagentsMetadata } : {}),
|
|
@@ -285,6 +288,32 @@ export class AgentAcpAdapter {
|
|
|
285
288
|
sessionId,
|
|
286
289
|
};
|
|
287
290
|
}
|
|
291
|
+
/**
|
|
292
|
+
* Store an initial message in the session.
|
|
293
|
+
* Called by the HTTP transport after sending the initial message via SSE.
|
|
294
|
+
*/
|
|
295
|
+
async storeInitialMessage(sessionId, content) {
|
|
296
|
+
const session = this.sessions.get(sessionId);
|
|
297
|
+
if (!session) {
|
|
298
|
+
logger.warn("Cannot store initial message - session not found", {
|
|
299
|
+
sessionId,
|
|
300
|
+
});
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
// Add the initial message as an assistant message
|
|
304
|
+
const initialMessage = {
|
|
305
|
+
role: "assistant",
|
|
306
|
+
content: [{ type: "text", text: content }],
|
|
307
|
+
timestamp: new Date().toISOString(),
|
|
308
|
+
};
|
|
309
|
+
session.messages.push(initialMessage);
|
|
310
|
+
// Save to disk if session persistence is enabled
|
|
311
|
+
await this.saveSessionToDisk(sessionId, session);
|
|
312
|
+
logger.debug("Stored initial message in session", {
|
|
313
|
+
sessionId,
|
|
314
|
+
contentPreview: content.slice(0, 100),
|
|
315
|
+
});
|
|
316
|
+
}
|
|
288
317
|
async loadSession(params) {
|
|
289
318
|
if (!this.storage) {
|
|
290
319
|
throw new Error("Session storage is not configured");
|
|
@@ -518,13 +547,11 @@ export class AgentAcpAdapter {
|
|
|
518
547
|
? session.context[session.context.length - 1]
|
|
519
548
|
: undefined;
|
|
520
549
|
// Calculate context size for this snapshot
|
|
521
|
-
// Build message pointers for the new context (previous messages
|
|
550
|
+
// Build message pointers for the new context (previous messages only, NOT the new user message)
|
|
551
|
+
// The new user message will be passed separately via the prompt parameter
|
|
522
552
|
const messageEntries = previousContext
|
|
523
|
-
? [
|
|
524
|
-
|
|
525
|
-
{ type: "pointer", index: session.messages.length - 1 },
|
|
526
|
-
]
|
|
527
|
-
: [{ type: "pointer", index: 0 }];
|
|
553
|
+
? [...previousContext.messages]
|
|
554
|
+
: [];
|
|
528
555
|
// Resolve message entries to actual messages
|
|
529
556
|
const contextMessages = [];
|
|
530
557
|
for (const entry of messageEntries) {
|
|
@@ -749,6 +776,23 @@ export class AgentAcpAdapter {
|
|
|
749
776
|
toolCallBlock.completedAt = Date.now();
|
|
750
777
|
}
|
|
751
778
|
}
|
|
779
|
+
// Forward tool_call_update with _meta to the client (for subagent connection info, etc.)
|
|
780
|
+
if (updateMsg._meta) {
|
|
781
|
+
logger.info("Forwarding tool_call_update with _meta to client", {
|
|
782
|
+
toolCallId: updateMsg.toolCallId,
|
|
783
|
+
status: updateMsg.status,
|
|
784
|
+
_meta: updateMsg._meta,
|
|
785
|
+
});
|
|
786
|
+
this.connection.sessionUpdate({
|
|
787
|
+
sessionId: params.sessionId,
|
|
788
|
+
update: {
|
|
789
|
+
sessionUpdate: "tool_call_update",
|
|
790
|
+
toolCallId: updateMsg.toolCallId,
|
|
791
|
+
status: updateMsg.status,
|
|
792
|
+
_meta: updateMsg._meta,
|
|
793
|
+
},
|
|
794
|
+
});
|
|
795
|
+
}
|
|
752
796
|
}
|
|
753
797
|
// Handle tool_output - update ToolCallBlock with output content
|
|
754
798
|
if ("sessionUpdate" in msg && msg.sessionUpdate === "tool_output") {
|
|
@@ -816,33 +860,53 @@ export class AgentAcpAdapter {
|
|
|
816
860
|
// Check if we already have a partial assistant message in messages
|
|
817
861
|
const lastMessage = session.messages[session.messages.length - 1];
|
|
818
862
|
let partialMessageIndex;
|
|
863
|
+
let userMessageIndex;
|
|
864
|
+
let isFirstToolInTurn;
|
|
819
865
|
if (lastMessage && lastMessage.role === "assistant") {
|
|
820
|
-
// Update existing partial message
|
|
866
|
+
// Update existing partial message (subsequent tool in same turn)
|
|
821
867
|
session.messages[session.messages.length - 1] =
|
|
822
868
|
partialAssistantMessage;
|
|
823
869
|
partialMessageIndex = session.messages.length - 1;
|
|
870
|
+
userMessageIndex = session.messages.length - 2;
|
|
871
|
+
isFirstToolInTurn = false;
|
|
824
872
|
}
|
|
825
873
|
else {
|
|
826
|
-
// Add new partial message
|
|
874
|
+
// Add new partial message (first tool in this turn)
|
|
827
875
|
session.messages.push(partialAssistantMessage);
|
|
828
876
|
partialMessageIndex = session.messages.length - 1;
|
|
877
|
+
userMessageIndex = session.messages.length - 2;
|
|
878
|
+
isFirstToolInTurn = true;
|
|
829
879
|
}
|
|
830
880
|
// Get the latest context
|
|
831
881
|
const latestContext = session.context.length > 0
|
|
832
882
|
? session.context[session.context.length - 1]
|
|
833
883
|
: undefined;
|
|
834
884
|
// Build message entries for the new context
|
|
835
|
-
// Check if we already have a pointer to this message (during mid-turn updates)
|
|
836
885
|
const existingMessages = latestContext?.messages ?? [];
|
|
837
|
-
|
|
838
|
-
const
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
886
|
+
// Check if we already have pointers to these messages
|
|
887
|
+
const hasUserPointer = existingMessages.some((entry) => entry.type === "pointer" && entry.index === userMessageIndex);
|
|
888
|
+
const hasAssistantPointer = existingMessages.some((entry) => entry.type === "pointer" &&
|
|
889
|
+
entry.index === partialMessageIndex);
|
|
890
|
+
let messageEntries;
|
|
891
|
+
if (isFirstToolInTurn && !hasUserPointer) {
|
|
892
|
+
// First tool: add both user and assistant pointers
|
|
893
|
+
messageEntries = [
|
|
843
894
|
...existingMessages,
|
|
895
|
+
{ type: "pointer", index: userMessageIndex },
|
|
844
896
|
{ type: "pointer", index: partialMessageIndex },
|
|
845
897
|
];
|
|
898
|
+
}
|
|
899
|
+
else if (!hasAssistantPointer) {
|
|
900
|
+
// Subsequent tool or user already added: just update/add assistant pointer
|
|
901
|
+
messageEntries = [
|
|
902
|
+
...existingMessages,
|
|
903
|
+
{ type: "pointer", index: partialMessageIndex },
|
|
904
|
+
];
|
|
905
|
+
}
|
|
906
|
+
else {
|
|
907
|
+
// Both pointers already exist (updating existing assistant message)
|
|
908
|
+
messageEntries = existingMessages;
|
|
909
|
+
}
|
|
846
910
|
// Resolve message entries to actual messages
|
|
847
911
|
const contextMessages = [];
|
|
848
912
|
for (const entry of messageEntries) {
|
|
@@ -985,13 +1049,19 @@ export class AgentAcpAdapter {
|
|
|
985
1049
|
? session.context[session.context.length - 1]
|
|
986
1050
|
: undefined;
|
|
987
1051
|
// Calculate final context size
|
|
988
|
-
// Build message pointers for the new context
|
|
1052
|
+
// Build message pointers for the new context (add both user and assistant messages)
|
|
1053
|
+
const userMessageIndex = session.messages.length - 2;
|
|
1054
|
+
const assistantMessageIndex = session.messages.length - 1;
|
|
989
1055
|
const messageEntries = previousContext
|
|
990
1056
|
? [
|
|
991
1057
|
...previousContext.messages,
|
|
992
|
-
{ type: "pointer", index:
|
|
1058
|
+
{ type: "pointer", index: userMessageIndex },
|
|
1059
|
+
{ type: "pointer", index: assistantMessageIndex },
|
|
993
1060
|
]
|
|
994
|
-
: [
|
|
1061
|
+
: [
|
|
1062
|
+
{ type: "pointer", index: userMessageIndex },
|
|
1063
|
+
{ type: "pointer", index: assistantMessageIndex },
|
|
1064
|
+
];
|
|
995
1065
|
// Resolve message entries to actual messages
|
|
996
1066
|
const contextMessages = [];
|
|
997
1067
|
for (const entry of messageEntries) {
|
package/dist/acp-server/http.js
CHANGED
|
@@ -66,7 +66,12 @@ export function makeHttpTransport(agent, agentDir, agentName) {
|
|
|
66
66
|
const outbound = new TransformStream();
|
|
67
67
|
const bridge = acp.ndJsonStream(outbound.writable, inbound.readable);
|
|
68
68
|
const agentRunner = "definition" in agent ? agent : makeRunnerFromDefinition(agent);
|
|
69
|
-
|
|
69
|
+
// Store adapter reference so we can call methods on it (e.g., storeInitialMessage)
|
|
70
|
+
let acpAdapter = null;
|
|
71
|
+
new acp.AgentSideConnection((conn) => {
|
|
72
|
+
acpAdapter = new AgentAcpAdapter(agentRunner, conn, agentDir, agentName);
|
|
73
|
+
return acpAdapter;
|
|
74
|
+
}, bridge);
|
|
70
75
|
const app = new Hono();
|
|
71
76
|
// Track active SSE streams by sessionId for direct output delivery
|
|
72
77
|
const sseStreams = new Map();
|
|
@@ -369,6 +374,7 @@ export function makeHttpTransport(agent, agentDir, agentName) {
|
|
|
369
374
|
},
|
|
370
375
|
_meta: {
|
|
371
376
|
isInitialMessage: true,
|
|
377
|
+
isReplay: true, // Mark as replay so UI adds it to messages
|
|
372
378
|
},
|
|
373
379
|
},
|
|
374
380
|
},
|
|
@@ -377,6 +383,10 @@ export function makeHttpTransport(agent, agentDir, agentName) {
|
|
|
377
383
|
event: "message",
|
|
378
384
|
data: JSON.stringify(initialMessage),
|
|
379
385
|
});
|
|
386
|
+
// Store the initial message in the session for persistence
|
|
387
|
+
if (acpAdapter) {
|
|
388
|
+
await acpAdapter.storeInitialMessage(sessionId, content);
|
|
389
|
+
}
|
|
380
390
|
logger.info("Sent initial message via SSE", {
|
|
381
391
|
sessionId,
|
|
382
392
|
contentPreview: content.slice(0, 100),
|
|
@@ -31,6 +31,10 @@ export declare const InitialMessageSchema: z.ZodObject<{
|
|
|
31
31
|
enabled: z.ZodBoolean;
|
|
32
32
|
content: z.ZodString;
|
|
33
33
|
}, z.core.$strip>;
|
|
34
|
+
/** UI configuration schema for controlling the chat interface appearance. */
|
|
35
|
+
export declare const UiConfigSchema: z.ZodObject<{
|
|
36
|
+
hideTopBar: z.ZodOptional<z.ZodBoolean>;
|
|
37
|
+
}, z.core.$strip>;
|
|
34
38
|
/** Agent definition schema. */
|
|
35
39
|
export declare const AgentDefinitionSchema: z.ZodObject<{
|
|
36
40
|
displayName: z.ZodOptional<z.ZodString>;
|
|
@@ -83,4 +87,7 @@ export declare const AgentDefinitionSchema: z.ZodObject<{
|
|
|
83
87
|
enabled: z.ZodBoolean;
|
|
84
88
|
content: z.ZodString;
|
|
85
89
|
}, z.core.$strip>>;
|
|
90
|
+
uiConfig: z.ZodOptional<z.ZodObject<{
|
|
91
|
+
hideTopBar: z.ZodOptional<z.ZodBoolean>;
|
|
92
|
+
}, z.core.$strip>>;
|
|
86
93
|
}, z.core.$strip>;
|
package/dist/definition/index.js
CHANGED
|
@@ -78,6 +78,11 @@ export const InitialMessageSchema = z.object({
|
|
|
78
78
|
/** The content of the initial message to send. Supports template variables like {{.AgentName}}. */
|
|
79
79
|
content: z.string(),
|
|
80
80
|
});
|
|
81
|
+
/** UI configuration schema for controlling the chat interface appearance. */
|
|
82
|
+
export const UiConfigSchema = z.object({
|
|
83
|
+
/** Whether to hide the top bar (session switcher, debugger link, settings). Useful for embedded/deployed mode. */
|
|
84
|
+
hideTopBar: z.boolean().optional(),
|
|
85
|
+
});
|
|
81
86
|
/** Agent definition schema. */
|
|
82
87
|
export const AgentDefinitionSchema = z.object({
|
|
83
88
|
/** Human-readable display name for the agent (shown in UI). */
|
|
@@ -93,4 +98,6 @@ export const AgentDefinitionSchema = z.object({
|
|
|
93
98
|
hooks: z.array(HookConfigSchema).optional(),
|
|
94
99
|
/** Configuration for an initial message the agent sends when a session starts. */
|
|
95
100
|
initialMessage: InitialMessageSchema.optional(),
|
|
101
|
+
/** UI configuration for controlling the chat interface appearance. */
|
|
102
|
+
uiConfig: UiConfigSchema.optional(),
|
|
96
103
|
});
|
|
@@ -56,6 +56,9 @@ export declare const zAgentRunnerParams: z.ZodObject<{
|
|
|
56
56
|
enabled: z.ZodBoolean;
|
|
57
57
|
content: z.ZodString;
|
|
58
58
|
}, z.core.$strip>>;
|
|
59
|
+
uiConfig: z.ZodOptional<z.ZodObject<{
|
|
60
|
+
hideTopBar: z.ZodOptional<z.ZodBoolean>;
|
|
61
|
+
}, z.core.$strip>>;
|
|
59
62
|
}, z.core.$strip>;
|
|
60
63
|
export type CreateAgentRunnerParams = z.infer<typeof zAgentRunnerParams>;
|
|
61
64
|
export interface SessionMessage {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { HookConfigSchema, InitialMessageSchema, McpConfigSchema, } from "../definition";
|
|
2
|
+
import { HookConfigSchema, InitialMessageSchema, McpConfigSchema, UiConfigSchema, } from "../definition";
|
|
3
3
|
import { zToolType } from "./tools";
|
|
4
4
|
export const zAgentRunnerParams = z.object({
|
|
5
5
|
displayName: z.string().optional(),
|
|
@@ -12,4 +12,5 @@ export const zAgentRunnerParams = z.object({
|
|
|
12
12
|
mcps: z.array(McpConfigSchema).optional(),
|
|
13
13
|
hooks: z.array(HookConfigSchema).optional(),
|
|
14
14
|
initialMessage: InitialMessageSchema.optional(),
|
|
15
|
+
uiConfig: UiConfigSchema.optional(),
|
|
15
16
|
});
|
|
@@ -13,6 +13,7 @@ import { makeBrowserTools } from "./tools/browser";
|
|
|
13
13
|
import { makeFilesystemTools } from "./tools/filesystem";
|
|
14
14
|
import { makeGenerateImageTool } from "./tools/generate_image";
|
|
15
15
|
import { SUBAGENT_TOOL_NAME } from "./tools/subagent";
|
|
16
|
+
import { hashQuery, queryToToolCallId, subagentEvents, } from "./tools/subagent-connections";
|
|
16
17
|
import { TODO_WRITE_TOOL_NAME, todoWrite } from "./tools/todo";
|
|
17
18
|
import { makeWebSearchTools } from "./tools/web_search";
|
|
18
19
|
const _logger = createLogger("agent-runner");
|
|
@@ -85,6 +86,56 @@ export class LangchainAgent {
|
|
|
85
86
|
telemetry.setBaseAttributes({
|
|
86
87
|
"agent.session_id": req.sessionId,
|
|
87
88
|
});
|
|
89
|
+
const subagentUpdateQueue = [];
|
|
90
|
+
let subagentUpdateResolver = null;
|
|
91
|
+
// Listen for subagent connection events - resolve any waiting promise immediately
|
|
92
|
+
const onSubagentConnection = (event) => {
|
|
93
|
+
_logger.info("Received subagent connection event", {
|
|
94
|
+
toolCallId: event.toolCallId,
|
|
95
|
+
port: event.port,
|
|
96
|
+
sessionId: event.sessionId,
|
|
97
|
+
});
|
|
98
|
+
if (subagentUpdateResolver) {
|
|
99
|
+
// If someone is waiting, resolve immediately
|
|
100
|
+
const resolver = subagentUpdateResolver;
|
|
101
|
+
subagentUpdateResolver = null;
|
|
102
|
+
resolver(event);
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
// Otherwise queue for later
|
|
106
|
+
subagentUpdateQueue.push(event);
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
subagentEvents.on("connection", onSubagentConnection);
|
|
110
|
+
// Helper to get next subagent update (returns immediately if queued, otherwise waits)
|
|
111
|
+
const waitForSubagentUpdate = () => {
|
|
112
|
+
if (subagentUpdateQueue.length > 0) {
|
|
113
|
+
return Promise.resolve(subagentUpdateQueue.shift());
|
|
114
|
+
}
|
|
115
|
+
return new Promise((resolve) => {
|
|
116
|
+
subagentUpdateResolver = resolve;
|
|
117
|
+
});
|
|
118
|
+
};
|
|
119
|
+
// Helper to check and yield all pending subagent updates
|
|
120
|
+
async function* yieldPendingSubagentUpdates() {
|
|
121
|
+
while (subagentUpdateQueue.length > 0) {
|
|
122
|
+
const update = subagentUpdateQueue.shift();
|
|
123
|
+
_logger.info("Yielding queued subagent connection update", {
|
|
124
|
+
toolCallId: update.toolCallId,
|
|
125
|
+
subagentPort: update.port,
|
|
126
|
+
subagentSessionId: update.sessionId,
|
|
127
|
+
});
|
|
128
|
+
yield {
|
|
129
|
+
sessionUpdate: "tool_call_update",
|
|
130
|
+
toolCallId: update.toolCallId,
|
|
131
|
+
_meta: {
|
|
132
|
+
messageId: req.messageId,
|
|
133
|
+
subagentPort: update.port,
|
|
134
|
+
subagentSessionId: update.sessionId,
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
}
|
|
88
139
|
// Start telemetry span for entire invocation
|
|
89
140
|
const invocationSpan = telemetry.startSpan("agent.invoke", {
|
|
90
141
|
"agent.model": this.definition.model,
|
|
@@ -425,7 +476,53 @@ export class LangchainAgent {
|
|
|
425
476
|
recursionLimit: 200,
|
|
426
477
|
callbacks: [otelCallbacks],
|
|
427
478
|
}));
|
|
428
|
-
|
|
479
|
+
// Use manual iteration with Promise.race to interleave stream events with subagent updates
|
|
480
|
+
const streamIterator = (await stream)[Symbol.asyncIterator]();
|
|
481
|
+
let streamDone = false;
|
|
482
|
+
let pendingStreamPromise = null;
|
|
483
|
+
while (!streamDone) {
|
|
484
|
+
// Get or create the stream promise (reuse if still pending from last iteration)
|
|
485
|
+
const nextStreamPromise = pendingStreamPromise ?? streamIterator.next();
|
|
486
|
+
pendingStreamPromise = nextStreamPromise; // Track it
|
|
487
|
+
// Create subagent wait promise (only if no queued updates)
|
|
488
|
+
const subagentPromise = waitForSubagentUpdate();
|
|
489
|
+
// Use Promise.race, but we need to handle both outcomes
|
|
490
|
+
const result = await Promise.race([
|
|
491
|
+
nextStreamPromise.then((r) => ({
|
|
492
|
+
type: "stream",
|
|
493
|
+
result: r,
|
|
494
|
+
})),
|
|
495
|
+
subagentPromise.then((u) => ({ type: "subagent", update: u })),
|
|
496
|
+
]);
|
|
497
|
+
if (result.type === "subagent") {
|
|
498
|
+
// Got a subagent update - yield it immediately
|
|
499
|
+
const update = result.update;
|
|
500
|
+
_logger.info("Yielding subagent connection update (via race)", {
|
|
501
|
+
toolCallId: update.toolCallId,
|
|
502
|
+
subagentPort: update.port,
|
|
503
|
+
subagentSessionId: update.sessionId,
|
|
504
|
+
});
|
|
505
|
+
yield {
|
|
506
|
+
sessionUpdate: "tool_call_update",
|
|
507
|
+
toolCallId: update.toolCallId,
|
|
508
|
+
_meta: {
|
|
509
|
+
messageId: req.messageId,
|
|
510
|
+
subagentPort: update.port,
|
|
511
|
+
subagentSessionId: update.sessionId,
|
|
512
|
+
},
|
|
513
|
+
};
|
|
514
|
+
// Continue - the stream promise is still pending, will be reused
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
517
|
+
// Got a stream item - clear the pending promise
|
|
518
|
+
pendingStreamPromise = null;
|
|
519
|
+
const { done, value: streamItem } = result.result;
|
|
520
|
+
if (done) {
|
|
521
|
+
streamDone = true;
|
|
522
|
+
break;
|
|
523
|
+
}
|
|
524
|
+
// Also yield any queued subagent updates before processing stream item
|
|
525
|
+
yield* yieldPendingSubagentUpdates();
|
|
429
526
|
const [streamMode, chunk] = streamItem;
|
|
430
527
|
if (streamMode === "updates") {
|
|
431
528
|
const updatesChunk = modelRequestSchema.safeParse(chunk);
|
|
@@ -517,6 +614,17 @@ export class LangchainAgent {
|
|
|
517
614
|
const subagentConfigs = taskTool?.subagentConfigs;
|
|
518
615
|
const subagentConfig = subagentConfigs?.find((config) => config.agentName === agentName);
|
|
519
616
|
prettyName = subagentConfig?.displayName ?? agentName;
|
|
617
|
+
// Register query hash -> toolCallId mapping for subagent connection info
|
|
618
|
+
if ("query" in toolCall.args &&
|
|
619
|
+
typeof toolCall.args.query === "string") {
|
|
620
|
+
const qHash = hashQuery(toolCall.args.query);
|
|
621
|
+
queryToToolCallId.set(qHash, toolCall.id);
|
|
622
|
+
telemetry.log("info", "Registered subagent query hash mapping", {
|
|
623
|
+
queryHash: qHash,
|
|
624
|
+
toolCallId: toolCall.id,
|
|
625
|
+
queryPreview: toolCall.args.query.slice(0, 50),
|
|
626
|
+
});
|
|
627
|
+
}
|
|
520
628
|
}
|
|
521
629
|
// Check if we already emitted a preliminary notification from early tool_use block
|
|
522
630
|
const alreadyEmittedPreliminary = preliminaryToolCallIds.has(toolCall.id);
|
|
@@ -750,6 +858,16 @@ export class LangchainAgent {
|
|
|
750
858
|
else {
|
|
751
859
|
throw new Error(`Unhandled stream mode: ${streamMode}`);
|
|
752
860
|
}
|
|
861
|
+
// Yield any pending subagent connection updates
|
|
862
|
+
yield* yieldPendingSubagentUpdates();
|
|
863
|
+
}
|
|
864
|
+
// Yield any remaining pending subagent connection updates after stream ends
|
|
865
|
+
yield* yieldPendingSubagentUpdates();
|
|
866
|
+
// Clean up subagent connection listener
|
|
867
|
+
subagentEvents.off("connection", onSubagentConnection);
|
|
868
|
+
// Cancel any pending wait
|
|
869
|
+
if (subagentUpdateResolver) {
|
|
870
|
+
subagentUpdateResolver = null;
|
|
753
871
|
}
|
|
754
872
|
// Log successful completion
|
|
755
873
|
telemetry.log("info", "Agent invocation completed", {
|
|
@@ -764,6 +882,8 @@ export class LangchainAgent {
|
|
|
764
882
|
};
|
|
765
883
|
}
|
|
766
884
|
catch (error) {
|
|
885
|
+
// Clean up subagent connection listener on error
|
|
886
|
+
subagentEvents.off("connection", onSubagentConnection);
|
|
767
887
|
// Log error and end span with error status
|
|
768
888
|
telemetry.log("error", "Agent invocation failed", {
|
|
769
889
|
error: error instanceof Error ? error.message : String(error),
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
/**
|
|
3
|
+
* Registry for subagent connection info.
|
|
4
|
+
* Maps query hash to connection info so the runner can emit tool_call_update.
|
|
5
|
+
*/
|
|
6
|
+
export interface SubagentConnectionInfo {
|
|
7
|
+
port: number;
|
|
8
|
+
sessionId: string;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Event emitter for subagent connection events.
|
|
12
|
+
* The runner listens to these events and emits tool_call_update.
|
|
13
|
+
*/
|
|
14
|
+
export declare const subagentEvents: EventEmitter<[never]>;
|
|
15
|
+
/**
|
|
16
|
+
* Maps query hash to toolCallId.
|
|
17
|
+
* Set by the runner when it sees a subagent tool_call.
|
|
18
|
+
*/
|
|
19
|
+
export declare const queryToToolCallId: Map<string, string>;
|
|
20
|
+
/**
|
|
21
|
+
* Generate a hash from the query string for correlation.
|
|
22
|
+
*/
|
|
23
|
+
export declare function hashQuery(query: string): string;
|
|
24
|
+
/**
|
|
25
|
+
* Called by the subagent tool when connection is established.
|
|
26
|
+
* Emits an event that the runner can listen to.
|
|
27
|
+
*/
|
|
28
|
+
export declare function emitSubagentConnection(queryHash: string, connectionInfo: SubagentConnectionInfo): void;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
3
|
+
import { createLogger } from "@townco/core";
|
|
4
|
+
const logger = createLogger("subagent-connections");
|
|
5
|
+
/**
|
|
6
|
+
* Event emitter for subagent connection events.
|
|
7
|
+
* The runner listens to these events and emits tool_call_update.
|
|
8
|
+
*/
|
|
9
|
+
export const subagentEvents = new EventEmitter();
|
|
10
|
+
/**
|
|
11
|
+
* Maps query hash to toolCallId.
|
|
12
|
+
* Set by the runner when it sees a subagent tool_call.
|
|
13
|
+
*/
|
|
14
|
+
export const queryToToolCallId = new Map();
|
|
15
|
+
/**
|
|
16
|
+
* Generate a hash from the query string for correlation.
|
|
17
|
+
*/
|
|
18
|
+
export function hashQuery(query) {
|
|
19
|
+
const hash = createHash("sha256").update(query).digest("hex").slice(0, 16);
|
|
20
|
+
logger.debug("Generated query hash", {
|
|
21
|
+
queryPreview: query.slice(0, 50),
|
|
22
|
+
hash,
|
|
23
|
+
});
|
|
24
|
+
return hash;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Called by the subagent tool when connection is established.
|
|
28
|
+
* Emits an event that the runner can listen to.
|
|
29
|
+
*/
|
|
30
|
+
export function emitSubagentConnection(queryHash, connectionInfo) {
|
|
31
|
+
logger.info("emitSubagentConnection called", {
|
|
32
|
+
queryHash,
|
|
33
|
+
port: connectionInfo.port,
|
|
34
|
+
sessionId: connectionInfo.sessionId,
|
|
35
|
+
registeredHashes: Array.from(queryToToolCallId.keys()),
|
|
36
|
+
});
|
|
37
|
+
const toolCallId = queryToToolCallId.get(queryHash);
|
|
38
|
+
if (toolCallId) {
|
|
39
|
+
logger.info("Found toolCallId for queryHash, emitting connection event", {
|
|
40
|
+
queryHash,
|
|
41
|
+
toolCallId,
|
|
42
|
+
port: connectionInfo.port,
|
|
43
|
+
sessionId: connectionInfo.sessionId,
|
|
44
|
+
});
|
|
45
|
+
subagentEvents.emit("connection", {
|
|
46
|
+
toolCallId,
|
|
47
|
+
...connectionInfo,
|
|
48
|
+
});
|
|
49
|
+
// Clean up the mapping
|
|
50
|
+
queryToToolCallId.delete(queryHash);
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
logger.warn("No toolCallId found for queryHash", {
|
|
54
|
+
queryHash,
|
|
55
|
+
registeredHashes: Array.from(queryToToolCallId.keys()),
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -7,6 +7,7 @@ import { createLogger as coreCreateLogger } from "@townco/core";
|
|
|
7
7
|
import { z } from "zod";
|
|
8
8
|
import { SUBAGENT_MODE_KEY } from "../../../acp-server/adapter.js";
|
|
9
9
|
import { findAvailablePort } from "./port-utils.js";
|
|
10
|
+
import { emitSubagentConnection, hashQuery } from "./subagent-connections.js";
|
|
10
11
|
/**
|
|
11
12
|
* Name of the Task tool created by makeSubagentsTool
|
|
12
13
|
*/
|
|
@@ -100,7 +101,7 @@ export function makeSubagentsTool(configs) {
|
|
|
100
101
|
type: "direct",
|
|
101
102
|
name: SUBAGENT_TOOL_NAME,
|
|
102
103
|
prettyName: "Subagent",
|
|
103
|
-
icon: "
|
|
104
|
+
icon: "CircleDot",
|
|
104
105
|
description: `Launch a new agent to handle complex, multi-step tasks autonomously.
|
|
105
106
|
|
|
106
107
|
The Task tool launches specialized agents (subprocesses) that autonomously handle complex tasks. Each agent type has specific capabilities and tools available to it.
|
|
@@ -310,6 +311,9 @@ async function querySubagent(agentName, agentPath, agentWorkingDirectory, query)
|
|
|
310
311
|
if (!sessionId) {
|
|
311
312
|
throw new Error("No sessionId in session/new response");
|
|
312
313
|
}
|
|
314
|
+
// Emit connection info so the GUI can connect directly to this subagent's SSE
|
|
315
|
+
const queryHash = hashQuery(query);
|
|
316
|
+
emitSubagentConnection(queryHash, { port, sessionId });
|
|
313
317
|
// Step 3: Connect to SSE for receiving streaming responses
|
|
314
318
|
sseAbortController = new AbortController();
|
|
315
319
|
let responseText = "";
|