@poncho-ai/harness 0.16.1 → 0.18.0

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/src/harness.ts CHANGED
@@ -29,6 +29,8 @@ import { addPromptCacheBreakpoints } from "./prompt-cache.js";
29
29
  import { jsonSchemaToZod } from "./schema-converter.js";
30
30
  import type { SkillMetadata } from "./skill-context.js";
31
31
  import { createSkillTools, normalizeScriptPolicyPath } from "./skill-tools.js";
32
+ import { createSubagentTools } from "./subagent-tools.js";
33
+ import type { SubagentManager } from "./subagent-manager.js";
32
34
  import { LatitudeTelemetry } from "@latitude-data/telemetry";
33
35
  import { diag, DiagLogLevel } from "@opentelemetry/api";
34
36
  import {
@@ -502,6 +504,7 @@ export class AgentHarness {
502
504
 
503
505
  private parsedAgent?: ParsedAgent;
504
506
  private mcpBridge?: LocalMcpBridge;
507
+ private subagentManager?: SubagentManager;
505
508
 
506
509
  private resolveToolAccess(toolName: string): ToolAccess {
507
510
  const tools = this.loadedConfig?.tools;
@@ -547,6 +550,21 @@ export class AgentHarness {
547
550
  }
548
551
  }
549
552
 
553
+ unregisterTools(names: string[]): void {
554
+ this.dispatcher.unregisterMany(names);
555
+ }
556
+
557
+ setSubagentManager(manager: SubagentManager): void {
558
+ this.subagentManager = manager;
559
+ this.dispatcher.registerMany(
560
+ createSubagentTools(
561
+ manager,
562
+ () => this._currentRunConversationId,
563
+ () => this._currentRunOwnerId ?? "anonymous",
564
+ ),
565
+ );
566
+ }
567
+
550
568
  private registerConfiguredBuiltInTools(config: PonchoConfig | undefined): void {
551
569
  for (const tool of createDefaultTools(this.workingDir)) {
552
570
  if (this.isToolEnabled(tool.name)) {
@@ -1060,6 +1078,8 @@ export class AgentHarness {
1060
1078
 
1061
1079
  /** Conversation ID of the currently executing run (set during run, cleared after). */
1062
1080
  private _currentRunConversationId?: string;
1081
+ /** Owner ID of the currently executing run (used by subagent tools). */
1082
+ private _currentRunOwnerId?: string;
1063
1083
 
1064
1084
  get browserSession(): unknown {
1065
1085
  return this._browserSession;
@@ -1180,8 +1200,12 @@ export class AgentHarness {
1180
1200
  }
1181
1201
  await this.refreshSkillsIfChanged();
1182
1202
 
1183
- // Track which conversation this run belongs to so browser tools resolve the right session
1203
+ // Track which conversation/owner this run belongs to so browser & subagent tools resolve correctly
1184
1204
  this._currentRunConversationId = input.conversationId;
1205
+ const ownerParam = input.parameters?.__ownerId;
1206
+ if (typeof ownerParam === "string") {
1207
+ this._currentRunOwnerId = ownerParam;
1208
+ }
1185
1209
 
1186
1210
  const agent = this.parsedAgent as ParsedAgent;
1187
1211
  const runId = `run_${randomUUID()}`;
@@ -1658,15 +1682,14 @@ ${boundedMainMemory.trim()}`
1658
1682
  isEnabled: telemetryEnabled && !!this.latitudeTelemetry,
1659
1683
  },
1660
1684
  });
1661
- // Stream text chunksenforce overall run timeout per chunk.
1662
- // The top-of-step timeout check cannot fire while we are
1663
- // blocked inside the textStream async iterator, so we race
1664
- // each next() call against the remaining time budget.
1685
+ // Stream full responseuse fullStream to get visibility into
1686
+ // tool-call generation (tool-input-start) in addition to text deltas.
1687
+ // Enforce overall run timeout per part.
1665
1688
  let fullText = "";
1666
1689
  let chunkCount = 0;
1667
1690
  const hasRunTimeout = timeoutMs > 0;
1668
1691
  const streamDeadline = hasRunTimeout ? start + timeoutMs : 0;
1669
- const textIterator = result.textStream[Symbol.asyncIterator]();
1692
+ const fullStreamIterator = result.fullStream[Symbol.asyncIterator]();
1670
1693
  try {
1671
1694
  while (true) {
1672
1695
  if (isCancelled()) {
@@ -1690,21 +1713,17 @@ ${boundedMainMemory.trim()}`
1690
1713
  return;
1691
1714
  }
1692
1715
  }
1693
- // Use a shorter timeout for the first chunk to detect
1694
- // non-responsive models quickly instead of waiting minutes.
1695
- // When no run timeout is set, only the first chunk is time-bounded.
1696
1716
  const remaining = hasRunTimeout ? streamDeadline - now() : Infinity;
1697
1717
  const timeout = chunkCount === 0
1698
1718
  ? Math.min(remaining, FIRST_CHUNK_TIMEOUT_MS)
1699
1719
  : hasRunTimeout ? remaining : 0;
1700
- let nextChunk: IteratorResult<string> | null;
1720
+ let nextPart: IteratorResult<(typeof result.fullStream) extends AsyncIterable<infer T> ? T : never> | null;
1701
1721
  if (timeout <= 0 && chunkCount > 0) {
1702
- // No time budget — await the stream directly (development mode, no run timeout)
1703
- nextChunk = await textIterator.next();
1722
+ nextPart = await fullStreamIterator.next();
1704
1723
  } else {
1705
1724
  let timer: ReturnType<typeof setTimeout> | undefined;
1706
- nextChunk = await Promise.race([
1707
- textIterator.next(),
1725
+ nextPart = await Promise.race([
1726
+ fullStreamIterator.next(),
1708
1727
  new Promise<null>((resolve) => {
1709
1728
  timer = setTimeout(() => resolve(null), timeout);
1710
1729
  }),
@@ -1712,16 +1731,14 @@ ${boundedMainMemory.trim()}`
1712
1731
  clearTimeout(timer);
1713
1732
  }
1714
1733
 
1715
- if (nextChunk === null) {
1734
+ if (nextPart === null) {
1716
1735
  const isFirstChunk = chunkCount === 0;
1717
1736
  console.error(
1718
1737
  `[poncho][harness] Stream timeout waiting for ${isFirstChunk ? "first" : "next"} chunk: model="${modelName}", step=${step}, chunks=${chunkCount}, elapsed=${now() - start}ms`,
1719
1738
  );
1720
1739
  if (isFirstChunk) {
1721
- // Throw so the step-level retry logic can handle it.
1722
1740
  throw new FirstChunkTimeoutError(modelName, FIRST_CHUNK_TIMEOUT_MS);
1723
1741
  }
1724
- // Mid-stream timeout: not retryable (partial response would be lost)
1725
1742
  yield pushEvent({
1726
1743
  type: "run:error",
1727
1744
  runId,
@@ -1733,14 +1750,20 @@ ${boundedMainMemory.trim()}`
1733
1750
  return;
1734
1751
  }
1735
1752
 
1736
- if (nextChunk.done) break;
1737
- chunkCount += 1;
1738
- fullText += nextChunk.value;
1739
- yield pushEvent({ type: "model:chunk", content: nextChunk.value });
1753
+ if (nextPart.done) break;
1754
+ const part = nextPart.value;
1755
+
1756
+ if (part.type === "text-delta") {
1757
+ chunkCount += 1;
1758
+ fullText += part.text;
1759
+ yield pushEvent({ type: "model:chunk", content: part.text });
1760
+ } else if (part.type === "tool-input-start") {
1761
+ chunkCount += 1;
1762
+ yield pushEvent({ type: "tool:generating", tool: part.toolName, toolCallId: part.id });
1763
+ }
1740
1764
  }
1741
1765
  } finally {
1742
- // Best-effort cleanup of the underlying stream/connection.
1743
- textIterator.return?.(undefined)?.catch?.(() => {});
1766
+ fullStreamIterator.return?.(undefined)?.catch?.(() => {});
1744
1767
  }
1745
1768
 
1746
1769
  if (isCancelled()) {
@@ -1749,8 +1772,6 @@ ${boundedMainMemory.trim()}`
1749
1772
  }
1750
1773
 
1751
1774
  // Check finish reason for error / abnormal completions.
1752
- // textStream silently swallows model-level errors – they only
1753
- // surface through finishReason (or fullStream, which we don't use).
1754
1775
  const finishReason = await result.finishReason;
1755
1776
 
1756
1777
  if (finishReason === "error") {
@@ -1859,6 +1880,7 @@ ${boundedMainMemory.trim()}`
1859
1880
  workingDir: this.workingDir,
1860
1881
  parameters: input.parameters ?? {},
1861
1882
  abortSignal: input.abortSignal,
1883
+ conversationId: input.conversationId,
1862
1884
  };
1863
1885
 
1864
1886
  const toolResultsForModel: Array<{
@@ -1995,11 +2017,14 @@ ${boundedMainMemory.trim()}`
1995
2017
  });
1996
2018
  } else {
1997
2019
  span?.end({ result: { value: result.output ?? null, isError: false } });
2020
+ const serialized = JSON.stringify(result.output ?? null);
2021
+ const outputTokenEstimate = Math.ceil(serialized.length / 4);
1998
2022
  yield pushEvent({
1999
2023
  type: "tool:completed",
2000
2024
  tool: result.tool,
2001
2025
  output: result.output,
2002
2026
  duration: now() - batchStart,
2027
+ outputTokenEstimate,
2003
2028
  });
2004
2029
 
2005
2030
  const { mediaItems, strippedOutput } = extractMediaFromToolOutput(result.output);
package/src/index.ts CHANGED
@@ -14,5 +14,7 @@ export * from "./state.js";
14
14
  export * from "./upload-store.js";
15
15
  export * from "./telemetry.js";
16
16
  export * from "./tool-dispatcher.js";
17
+ export * from "./subagent-manager.js";
18
+ export * from "./subagent-tools.js";
17
19
  export { defineTool } from "@poncho-ai/sdk";
18
20
  export type { ToolDefinition } from "@poncho-ai/sdk";
@@ -1,14 +1,3 @@
1
- /**
2
- * Latitude telemetry integration for Vercel AI SDK
3
- *
4
- * TODO: Implement proper Vercel AI SDK telemetry integration using:
5
- * - LatitudeTelemetry.capture() wrapper around streamText()
6
- * - experimental_telemetry: { isEnabled: true } in streamText() options
7
- *
8
- * This requires @latitude-data/telemetry package which has official
9
- * Vercel AI SDK support.
10
- */
11
-
12
1
  export interface LatitudeCaptureConfig {
13
2
  apiKeyEnv?: string;
14
3
  projectIdEnv?: string;
@@ -17,8 +6,9 @@ export interface LatitudeCaptureConfig {
17
6
  }
18
7
 
19
8
  /**
20
- * Placeholder for Latitude telemetry integration
21
- * This will be properly implemented once Vercel AI SDK migration is complete
9
+ * Reads and validates Latitude telemetry configuration from environment
10
+ * variables. The actual telemetry capture is handled by LatitudeTelemetry
11
+ * from @latitude-data/telemetry in harness.ts (via runWithTelemetry).
22
12
  */
23
13
  export class LatitudeCapture {
24
14
  private readonly apiKey?: string;
package/src/mcp.ts CHANGED
@@ -12,6 +12,7 @@ export interface RemoteMcpServerConfig {
12
12
  type: "bearer";
13
13
  tokenEnv?: string;
14
14
  };
15
+ headers?: Record<string, string>;
15
16
  timeoutMs?: number;
16
17
  reconnectAttempts?: number;
17
18
  reconnectDelayMs?: number;
@@ -46,18 +47,26 @@ class StreamableHttpMcpRpcClient implements McpRpcClient {
46
47
  private readonly endpoint: string;
47
48
  private readonly timeoutMs: number;
48
49
  private readonly bearerToken?: string;
50
+ private readonly customHeaders: Record<string, string>;
49
51
  private idCounter = 1;
50
52
  private initialized = false;
51
53
  private sessionId?: string;
52
54
 
53
- constructor(endpoint: string, timeoutMs = 10_000, bearerToken?: string) {
55
+ constructor(
56
+ endpoint: string,
57
+ timeoutMs = 10_000,
58
+ bearerToken?: string,
59
+ customHeaders?: Record<string, string>,
60
+ ) {
54
61
  this.endpoint = endpoint;
55
62
  this.timeoutMs = timeoutMs;
56
63
  this.bearerToken = bearerToken;
64
+ this.customHeaders = customHeaders ?? {};
57
65
  }
58
66
 
59
67
  private buildHeaders(accept: string): Record<string, string> {
60
68
  const headers: Record<string, string> = {
69
+ ...this.customHeaders,
61
70
  "Content-Type": "application/json",
62
71
  Accept: accept,
63
72
  };
@@ -367,6 +376,7 @@ export class LocalMcpBridge {
367
376
  server.url,
368
377
  server.timeoutMs ?? 10_000,
369
378
  server.auth?.tokenEnv ? process.env[server.auth.tokenEnv] : undefined,
379
+ server.headers,
370
380
  ),
371
381
  );
372
382
  }
package/src/state.ts CHANGED
@@ -38,12 +38,22 @@ export interface Conversation {
38
38
  }>;
39
39
  ownerId: string;
40
40
  tenantId: string | null;
41
+ contextTokens?: number;
42
+ contextWindow?: number;
43
+ parentConversationId?: string;
44
+ subagentMeta?: {
45
+ task: string;
46
+ status: "running" | "completed" | "error" | "stopped";
47
+ result?: import("@poncho-ai/sdk").RunResult;
48
+ error?: import("@poncho-ai/sdk").AgentFailure;
49
+ };
41
50
  createdAt: number;
42
51
  updatedAt: number;
43
52
  }
44
53
 
45
54
  export interface ConversationStore {
46
55
  list(ownerId?: string): Promise<Conversation[]>;
56
+ listSummaries(ownerId?: string): Promise<ConversationSummary[]>;
47
57
  get(conversationId: string): Promise<Conversation | undefined>;
48
58
  create(ownerId?: string, title?: string): Promise<Conversation>;
49
59
  update(conversation: Conversation): Promise<void>;
@@ -213,13 +223,30 @@ export class InMemoryConversationStore implements ConversationStore {
213
223
  }
214
224
  }
215
225
 
216
- async list(ownerId = DEFAULT_OWNER): Promise<Conversation[]> {
226
+ async list(ownerId?: string): Promise<Conversation[]> {
217
227
  this.purgeExpired();
218
228
  return Array.from(this.conversations.values())
219
- .filter((conversation) => conversation.ownerId === ownerId)
229
+ .filter((conversation) => !ownerId || conversation.ownerId === ownerId)
220
230
  .sort((a, b) => b.updatedAt - a.updatedAt);
221
231
  }
222
232
 
233
+ async listSummaries(ownerId?: string): Promise<ConversationSummary[]> {
234
+ this.purgeExpired();
235
+ return Array.from(this.conversations.values())
236
+ .filter((c) => !ownerId || c.ownerId === ownerId)
237
+ .sort((a, b) => b.updatedAt - a.updatedAt)
238
+ .map((c) => ({
239
+ conversationId: c.conversationId,
240
+ title: c.title,
241
+ updatedAt: c.updatedAt,
242
+ createdAt: c.createdAt,
243
+ ownerId: c.ownerId,
244
+ parentConversationId: c.parentConversationId,
245
+ messageCount: c.messages.length,
246
+ hasPendingApprovals: Array.isArray(c.pendingApprovals) && c.pendingApprovals.length > 0,
247
+ }));
248
+ }
249
+
223
250
  async get(conversationId: string): Promise<Conversation | undefined> {
224
251
  this.purgeExpired();
225
252
  return this.conversations.get(conversationId);
@@ -266,14 +293,29 @@ export class InMemoryConversationStore implements ConversationStore {
266
293
  }
267
294
  }
268
295
 
296
+ export type ConversationSummary = {
297
+ conversationId: string;
298
+ title: string;
299
+ updatedAt: number;
300
+ createdAt?: number;
301
+ ownerId: string;
302
+ parentConversationId?: string;
303
+ messageCount?: number;
304
+ hasPendingApprovals?: boolean;
305
+ };
306
+
269
307
  type ConversationStoreFile = {
270
308
  schemaVersion: string;
271
309
  conversations: Array<{
272
310
  conversationId: string;
273
311
  title: string;
274
312
  updatedAt: number;
313
+ createdAt?: number;
275
314
  ownerId: string;
276
315
  fileName: string;
316
+ parentConversationId?: string;
317
+ messageCount?: number;
318
+ hasPendingApprovals?: boolean;
277
319
  }>;
278
320
  };
279
321
 
@@ -342,8 +384,12 @@ class FileConversationStore implements ConversationStore {
342
384
  conversationId: conversation.conversationId,
343
385
  title: conversation.title,
344
386
  updatedAt: conversation.updatedAt,
387
+ createdAt: conversation.createdAt,
345
388
  ownerId: conversation.ownerId,
346
389
  fileName: entry.name,
390
+ parentConversationId: conversation.parentConversationId,
391
+ messageCount: conversation.messages.length,
392
+ hasPendingApprovals: Array.isArray(conversation.pendingApprovals) && conversation.pendingApprovals.length > 0,
347
393
  });
348
394
  }
349
395
  } catch {
@@ -369,6 +415,17 @@ class FileConversationStore implements ConversationStore {
369
415
  for (const conversation of parsed.conversations ?? []) {
370
416
  this.conversations.set(conversation.conversationId, conversation);
371
417
  }
418
+ // Rebuild if any entry is from an older index format (missing messageCount)
419
+ let needsRebuild = false;
420
+ for (const entry of this.conversations.values()) {
421
+ if (entry.messageCount === undefined) {
422
+ needsRebuild = true;
423
+ break;
424
+ }
425
+ }
426
+ if (needsRebuild) {
427
+ await this.rebuildIndexFromFiles();
428
+ }
372
429
  } catch {
373
430
  await this.rebuildIndexFromFiles();
374
431
  }
@@ -385,18 +442,22 @@ class FileConversationStore implements ConversationStore {
385
442
  conversationId: conversation.conversationId,
386
443
  title: conversation.title,
387
444
  updatedAt: conversation.updatedAt,
445
+ createdAt: conversation.createdAt,
388
446
  ownerId: conversation.ownerId,
389
447
  fileName,
448
+ parentConversationId: conversation.parentConversationId,
449
+ messageCount: conversation.messages.length,
450
+ hasPendingApprovals: Array.isArray(conversation.pendingApprovals) && conversation.pendingApprovals.length > 0,
390
451
  });
391
452
  await this.writeIndex();
392
453
  });
393
454
  await this.writing;
394
455
  }
395
456
 
396
- async list(ownerId = DEFAULT_OWNER): Promise<Conversation[]> {
457
+ async list(ownerId?: string): Promise<Conversation[]> {
397
458
  await this.ensureLoaded();
398
459
  const summaries = Array.from(this.conversations.values())
399
- .filter((conversation) => conversation.ownerId === ownerId)
460
+ .filter((conversation) => !ownerId || conversation.ownerId === ownerId)
400
461
  .sort((a, b) => b.updatedAt - a.updatedAt);
401
462
  const conversations: Conversation[] = [];
402
463
  for (const summary of summaries) {
@@ -408,6 +469,23 @@ class FileConversationStore implements ConversationStore {
408
469
  return conversations;
409
470
  }
410
471
 
472
+ async listSummaries(ownerId?: string): Promise<ConversationSummary[]> {
473
+ await this.ensureLoaded();
474
+ return Array.from(this.conversations.values())
475
+ .filter((c) => !ownerId || c.ownerId === ownerId)
476
+ .sort((a, b) => b.updatedAt - a.updatedAt)
477
+ .map((c) => ({
478
+ conversationId: c.conversationId,
479
+ title: c.title,
480
+ updatedAt: c.updatedAt,
481
+ createdAt: c.createdAt,
482
+ ownerId: c.ownerId,
483
+ parentConversationId: c.parentConversationId,
484
+ messageCount: c.messageCount,
485
+ hasPendingApprovals: c.hasPendingApprovals,
486
+ }));
487
+ }
488
+
411
489
  async get(conversationId: string): Promise<Conversation | undefined> {
412
490
  await this.ensureLoaded();
413
491
  const summary = this.conversations.get(conversationId);
@@ -573,7 +651,11 @@ type ConversationMeta = {
573
651
  conversationId: string;
574
652
  title: string;
575
653
  updatedAt: number;
654
+ createdAt?: number;
576
655
  ownerId: string;
656
+ parentConversationId?: string;
657
+ messageCount?: number;
658
+ hasPendingApprovals?: boolean;
577
659
  };
578
660
 
579
661
  abstract class KeyValueConversationStoreBase implements ConversationStore {
@@ -669,11 +751,15 @@ abstract class KeyValueConversationStoreBase implements ConversationStore {
669
751
  }
670
752
  }
671
753
 
672
- async list(ownerId = DEFAULT_OWNER): Promise<Conversation[]> {
754
+ async list(ownerId?: string): Promise<Conversation[]> {
673
755
  const kv = await this.client();
674
756
  if (!kv) {
675
757
  return await this.memoryFallback.list(ownerId);
676
758
  }
759
+ if (!ownerId) {
760
+ // KV stores index per-owner; cross-owner listing not supported
761
+ return [];
762
+ }
677
763
  const ids = await this.getOwnerConversationIds(ownerId);
678
764
  const conversations: Conversation[] = [];
679
765
  for (const id of ids) {
@@ -690,6 +776,34 @@ abstract class KeyValueConversationStoreBase implements ConversationStore {
690
776
  return conversations.sort((a, b) => b.updatedAt - a.updatedAt);
691
777
  }
692
778
 
779
+ async listSummaries(ownerId?: string): Promise<ConversationSummary[]> {
780
+ const kv = await this.client();
781
+ if (!kv) {
782
+ return await this.memoryFallback.listSummaries(ownerId);
783
+ }
784
+ if (!ownerId) {
785
+ return [];
786
+ }
787
+ const ids = await this.getOwnerConversationIds(ownerId);
788
+ const summaries: ConversationSummary[] = [];
789
+ for (const id of ids) {
790
+ const meta = await this.getConversationMeta(id);
791
+ if (meta && meta.ownerId === ownerId) {
792
+ summaries.push({
793
+ conversationId: meta.conversationId,
794
+ title: meta.title,
795
+ updatedAt: meta.updatedAt,
796
+ createdAt: meta.createdAt,
797
+ ownerId: meta.ownerId,
798
+ parentConversationId: meta.parentConversationId,
799
+ messageCount: meta.messageCount,
800
+ hasPendingApprovals: meta.hasPendingApprovals,
801
+ });
802
+ }
803
+ }
804
+ return summaries.sort((a, b) => b.updatedAt - a.updatedAt);
805
+ }
806
+
693
807
  async get(conversationId: string): Promise<Conversation | undefined> {
694
808
  const kv = await this.client();
695
809
  if (!kv) {
@@ -741,7 +855,11 @@ abstract class KeyValueConversationStoreBase implements ConversationStore {
741
855
  conversationId: nextConversation.conversationId,
742
856
  title: nextConversation.title,
743
857
  updatedAt: nextConversation.updatedAt,
858
+ createdAt: nextConversation.createdAt,
744
859
  ownerId: nextConversation.ownerId,
860
+ parentConversationId: nextConversation.parentConversationId,
861
+ messageCount: nextConversation.messages.length,
862
+ hasPendingApprovals: Array.isArray(nextConversation.pendingApprovals) && nextConversation.pendingApprovals.length > 0,
745
863
  } satisfies ConversationMeta),
746
864
  this.ttl,
747
865
  );
@@ -0,0 +1,30 @@
1
+ import type { AgentFailure, Message, RunResult } from "@poncho-ai/sdk";
2
+
3
+ export interface SubagentResult {
4
+ subagentId: string;
5
+ status: "completed" | "error" | "stopped";
6
+ latestMessages?: Message[];
7
+ result?: RunResult;
8
+ error?: AgentFailure;
9
+ }
10
+
11
+ export interface SubagentSummary {
12
+ subagentId: string;
13
+ task: string;
14
+ status: string;
15
+ messageCount: number;
16
+ }
17
+
18
+ export interface SubagentManager {
19
+ spawn(opts: {
20
+ task: string;
21
+ parentConversationId: string;
22
+ ownerId: string;
23
+ }): Promise<SubagentResult>;
24
+
25
+ sendMessage(subagentId: string, message: string): Promise<SubagentResult>;
26
+
27
+ stop(subagentId: string): Promise<void>;
28
+
29
+ list(parentConversationId: string): Promise<SubagentSummary[]>;
30
+ }
@@ -0,0 +1,160 @@
1
+ import { defineTool, type Message, type ToolDefinition, getTextContent } from "@poncho-ai/sdk";
2
+ import type { SubagentManager, SubagentResult } from "./subagent-manager.js";
3
+
4
+ const LAST_MESSAGES_TO_RETURN = 10;
5
+
6
+ const summarizeResult = (r: SubagentResult): Record<string, unknown> => {
7
+ const summary: Record<string, unknown> = {
8
+ subagentId: r.subagentId,
9
+ status: r.status,
10
+ };
11
+ if (r.result) {
12
+ summary.result = {
13
+ status: r.result.status,
14
+ response: r.result.response,
15
+ steps: r.result.steps,
16
+ duration: r.result.duration,
17
+ };
18
+ }
19
+ if (r.error) {
20
+ summary.error = r.error;
21
+ }
22
+ if (r.latestMessages && r.latestMessages.length > 0) {
23
+ summary.latestMessages = r.latestMessages
24
+ .slice(-LAST_MESSAGES_TO_RETURN)
25
+ .map((m: Message) => ({
26
+ role: m.role,
27
+ content: getTextContent(m).slice(0, 2000),
28
+ }));
29
+ }
30
+ return summary;
31
+ };
32
+
33
+ export const createSubagentTools = (
34
+ manager: SubagentManager,
35
+ getConversationId: () => string | undefined,
36
+ getOwnerId: () => string,
37
+ ): ToolDefinition[] => [
38
+ defineTool({
39
+ name: "spawn_subagent",
40
+ description:
41
+ "Spawn a subagent to work on a task and wait for it to finish. The subagent is a full copy of " +
42
+ "yourself running in its own conversation context with access to the same tools (except memory writes). " +
43
+ "This call blocks until the subagent completes and returns its result.\n\n" +
44
+ "Guidelines:\n" +
45
+ "- Use subagents to parallelize work: call spawn_subagent multiple times in one response for independent sub-tasks -- they run concurrently.\n" +
46
+ "- Prefer doing work yourself for simple or quick tasks. Spawn subagents for substantial, self-contained work.\n" +
47
+ "- The subagent has no memory of your conversation -- write thorough, self-contained instructions in the task.",
48
+ inputSchema: {
49
+ type: "object",
50
+ properties: {
51
+ task: {
52
+ type: "string",
53
+ description:
54
+ "Thorough, self-contained instructions for the subagent. Include all relevant context, " +
55
+ "goals, and constraints -- the subagent starts with zero prior conversation history.",
56
+ },
57
+ },
58
+ required: ["task"],
59
+ additionalProperties: false,
60
+ },
61
+ handler: async (input) => {
62
+ const task = typeof input.task === "string" ? input.task : "";
63
+ if (!task.trim()) {
64
+ return { error: "task is required" };
65
+ }
66
+ const conversationId = getConversationId();
67
+ if (!conversationId) {
68
+ return { error: "no active conversation to spawn subagent from" };
69
+ }
70
+ const result = await manager.spawn({
71
+ task: task.trim(),
72
+ parentConversationId: conversationId,
73
+ ownerId: getOwnerId(),
74
+ });
75
+ return summarizeResult(result);
76
+ },
77
+ }),
78
+
79
+ defineTool({
80
+ name: "message_subagent",
81
+ description:
82
+ "Send a follow-up message to a completed or stopped subagent and wait for it to finish. " +
83
+ "This restarts the subagent with the new message and blocks until it completes. " +
84
+ "Only works when the subagent is not currently running.",
85
+ inputSchema: {
86
+ type: "object",
87
+ properties: {
88
+ subagent_id: {
89
+ type: "string",
90
+ description: "The subagent ID (from spawn_subagent result or list_subagents).",
91
+ },
92
+ message: {
93
+ type: "string",
94
+ description: "The follow-up instructions or message to send.",
95
+ },
96
+ },
97
+ required: ["subagent_id", "message"],
98
+ additionalProperties: false,
99
+ },
100
+ handler: async (input) => {
101
+ const subagentId = typeof input.subagent_id === "string" ? input.subagent_id : "";
102
+ const message = typeof input.message === "string" ? input.message : "";
103
+ if (!subagentId || !message.trim()) {
104
+ return { error: "subagent_id and message are required" };
105
+ }
106
+ const result = await manager.sendMessage(subagentId, message.trim());
107
+ return summarizeResult(result);
108
+ },
109
+ }),
110
+
111
+ defineTool({
112
+ name: "stop_subagent",
113
+ description:
114
+ "Stop a running subagent. The subagent's conversation is preserved but it will stop processing. " +
115
+ "Use this to cancel work that is no longer needed.",
116
+ inputSchema: {
117
+ type: "object",
118
+ properties: {
119
+ subagent_id: {
120
+ type: "string",
121
+ description: "The subagent ID (from spawn_subagent result or list_subagents).",
122
+ },
123
+ },
124
+ required: ["subagent_id"],
125
+ additionalProperties: false,
126
+ },
127
+ handler: async (input) => {
128
+ const subagentId = typeof input.subagent_id === "string" ? input.subagent_id : "";
129
+ if (!subagentId) {
130
+ return { error: "subagent_id is required" };
131
+ }
132
+ await manager.stop(subagentId);
133
+ return { message: `Subagent "${subagentId}" has been stopped.` };
134
+ },
135
+ }),
136
+
137
+ defineTool({
138
+ name: "list_subagents",
139
+ description:
140
+ "List all subagents that have been spawned in this conversation. Returns each subagent's ID, " +
141
+ "original task, current status, and message count. Use this to look up subagent IDs before " +
142
+ "calling message_subagent or stop_subagent.",
143
+ inputSchema: {
144
+ type: "object",
145
+ properties: {},
146
+ additionalProperties: false,
147
+ },
148
+ handler: async () => {
149
+ const conversationId = getConversationId();
150
+ if (!conversationId) {
151
+ return { error: "no active conversation" };
152
+ }
153
+ const subagents = await manager.list(conversationId);
154
+ if (subagents.length === 0) {
155
+ return { message: "No subagents have been spawned in this conversation." };
156
+ }
157
+ return { subagents };
158
+ },
159
+ }),
160
+ ];