@poncho-ai/harness 0.26.0 → 0.28.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
@@ -32,6 +32,7 @@ import { addPromptCacheBreakpoints } from "./prompt-cache.js";
32
32
  import { jsonSchemaToZod } from "./schema-converter.js";
33
33
  import type { SkillMetadata } from "./skill-context.js";
34
34
  import { createSkillTools, normalizeScriptPolicyPath } from "./skill-tools.js";
35
+ import { createSearchTools } from "./search-tools.js";
35
36
  import { createSubagentTools } from "./subagent-tools.js";
36
37
  import type { SubagentManager } from "./subagent-manager.js";
37
38
  import { LatitudeTelemetry } from "@latitude-data/telemetry";
@@ -562,7 +563,7 @@ export class AgentHarness {
562
563
  private insideTelemetryCapture = false;
563
564
  private _browserSession?: unknown;
564
565
  private _browserMod?: {
565
- createBrowserTools: (getSession: () => unknown, getConversationId: () => string) => ToolDefinition[];
566
+ createBrowserTools: (getSession: () => unknown, getConversationId?: () => string) => ToolDefinition[];
566
567
  BrowserSession: new (sessionId: string, config: Record<string, unknown>) => unknown;
567
568
  };
568
569
 
@@ -622,11 +623,7 @@ export class AgentHarness {
622
623
  setSubagentManager(manager: SubagentManager): void {
623
624
  this.subagentManager = manager;
624
625
  this.dispatcher.registerMany(
625
- createSubagentTools(
626
- manager,
627
- () => this._currentRunConversationId,
628
- () => this._currentRunOwnerId ?? "anonymous",
629
- ),
626
+ createSubagentTools(manager),
630
627
  );
631
628
  }
632
629
 
@@ -648,6 +645,11 @@ export class AgentHarness {
648
645
  if (this.isToolEnabled("delete_directory")) {
649
646
  this.registerIfMissing(createDeleteDirectoryTool(this.workingDir));
650
647
  }
648
+ for (const tool of createSearchTools()) {
649
+ if (this.isToolEnabled(tool.name)) {
650
+ this.registerIfMissing(tool);
651
+ }
652
+ }
651
653
  if (this.environment === "development" && this.isToolEnabled("poncho_docs")) {
652
654
  this.registerIfMissing(ponchoDocsTool);
653
655
  }
@@ -1165,7 +1167,7 @@ export class AgentHarness {
1165
1167
  private async initBrowserTools(config: PonchoConfig): Promise<void> {
1166
1168
  const spec = ["@poncho-ai", "browser"].join("/");
1167
1169
  let browserMod: {
1168
- createBrowserTools: (getSession: () => unknown, getConversationId: () => string) => ToolDefinition[];
1170
+ createBrowserTools: (getSession: () => unknown, getConversationId?: () => string) => ToolDefinition[];
1169
1171
  BrowserSession: new (sessionId: string, cfg?: Record<string, unknown>) => unknown;
1170
1172
  };
1171
1173
  try {
@@ -1209,7 +1211,10 @@ export class AgentHarness {
1209
1211
 
1210
1212
  const tools = browserMod.createBrowserTools(
1211
1213
  () => session,
1212
- () => this._currentRunConversationId ?? "__default__",
1214
+ // Backward compat: older @poncho-ai/browser versions expect a second
1215
+ // getConversationId callback. Current versions read from ToolContext
1216
+ // and ignore extra args.
1217
+ () => "__default__",
1213
1218
  );
1214
1219
  for (const tool of tools) {
1215
1220
  if (this.isToolEnabled(tool.name)) {
@@ -1218,10 +1223,6 @@ export class AgentHarness {
1218
1223
  }
1219
1224
  }
1220
1225
 
1221
- /** Conversation ID of the currently executing run (set during run, cleared after). */
1222
- private _currentRunConversationId?: string;
1223
- /** Owner ID of the currently executing run (used by subagent tools). */
1224
- private _currentRunOwnerId?: string;
1225
1226
 
1226
1227
  get browserSession(): unknown {
1227
1228
  return this._browserSession;
@@ -1369,13 +1370,6 @@ export class AgentHarness {
1369
1370
  await this.refreshAgentIfChanged();
1370
1371
  await this.refreshSkillsIfChanged();
1371
1372
 
1372
- // Track which conversation/owner this run belongs to so browser & subagent tools resolve correctly
1373
- this._currentRunConversationId = input.conversationId;
1374
- const ownerParam = input.parameters?.__ownerId;
1375
- if (typeof ownerParam === "string") {
1376
- this._currentRunOwnerId = ownerParam;
1377
- }
1378
-
1379
1373
  let agent = this.parsedAgent as ParsedAgent;
1380
1374
  const runId = `run_${randomUUID()}`;
1381
1375
  const start = now();
@@ -1385,9 +1379,9 @@ export class AgentHarness {
1385
1379
  ? 0 // no hard timeout in development unless explicitly configured
1386
1380
  : (configuredTimeout ?? 300) * 1000;
1387
1381
  const platformMaxDurationSec = Number(process.env.PONCHO_MAX_DURATION) || 0;
1388
- const softDeadlineMs = platformMaxDurationSec > 0
1389
- ? platformMaxDurationSec * 800
1390
- : 0;
1382
+ const softDeadlineMs = (input.disableSoftDeadline || platformMaxDurationSec <= 0)
1383
+ ? 0
1384
+ : platformMaxDurationSec * 800;
1391
1385
  const messages: Message[] = [...(input.messages ?? [])];
1392
1386
  const inputMessageCount = messages.length;
1393
1387
  const events: AgentEvent[] = [];
@@ -1541,6 +1535,18 @@ ${boundedMainMemory.trim()}`
1541
1535
  metadata: { timestamp: now(), id: randomUUID() },
1542
1536
  });
1543
1537
  }
1538
+ } else {
1539
+ // Continuation run (no explicit task). Some providers (Anthropic) require
1540
+ // the conversation to end with a user message. Inject a transient signal
1541
+ // that is sent to the LLM but never persisted to the conversation store.
1542
+ const lastMsg = messages[messages.length - 1];
1543
+ if (lastMsg && lastMsg.role !== "user") {
1544
+ messages.push({
1545
+ role: "user",
1546
+ content: "[System: Your previous turn was interrupted by a time limit. Continue from where you left off — do NOT repeat what you already said. Proceed directly with the next action or tool call.]",
1547
+ metadata: { timestamp: now(), id: randomUUID() },
1548
+ });
1549
+ }
1544
1550
  }
1545
1551
 
1546
1552
  let responseText = "";
@@ -1577,6 +1583,7 @@ ${boundedMainMemory.trim()}`
1577
1583
  tokens: { input: totalInputTokens, output: totalOutputTokens, cached: totalCachedTokens },
1578
1584
  duration: now() - start,
1579
1585
  continuation: true,
1586
+ continuationMessages: [...messages],
1580
1587
  maxSteps,
1581
1588
  };
1582
1589
  yield pushEvent({ type: "run:completed", runId, result });
@@ -1727,6 +1734,9 @@ ${boundedMainMemory.trim()}`
1727
1734
  } catch {
1728
1735
  // Not JSON, treat as regular assistant text.
1729
1736
  }
1737
+ if (!assistantText || assistantText.trim().length === 0) {
1738
+ return [];
1739
+ }
1730
1740
  return [{ role: "assistant" as const, content: assistantText }];
1731
1741
  }
1732
1742
 
@@ -2403,6 +2413,7 @@ ${boundedMainMemory.trim()}`
2403
2413
  tokens: { input: totalInputTokens, output: totalOutputTokens, cached: totalCachedTokens },
2404
2414
  duration: now() - start,
2405
2415
  continuation: true,
2416
+ continuationMessages: [...messages],
2406
2417
  maxSteps,
2407
2418
  };
2408
2419
  yield pushEvent({ type: "run:completed", runId, result });
package/src/index.ts CHANGED
@@ -9,6 +9,7 @@ export * from "./memory.js";
9
9
  export * from "./mcp.js";
10
10
  export * from "./model-factory.js";
11
11
  export * from "./schema-converter.js";
12
+ export * from "./search-tools.js";
12
13
  export * from "./skill-context.js";
13
14
  export * from "./skill-tools.js";
14
15
  export * from "./state.js";
package/src/kv-store.ts CHANGED
@@ -38,17 +38,27 @@ class UpstashKVStore implements RawKVStore {
38
38
  }
39
39
 
40
40
  async set(key: string, value: string): Promise<void> {
41
- await fetch(
42
- `${this.baseUrl}/set/${encodeURIComponent(key)}/${encodeURIComponent(value)}`,
43
- { method: "POST", headers: this.headers() },
44
- );
41
+ const response = await fetch(this.baseUrl, {
42
+ method: "POST",
43
+ headers: this.headers(),
44
+ body: JSON.stringify(["SET", key, value]),
45
+ });
46
+ if (!response.ok) {
47
+ const text = await response.text().catch(() => "");
48
+ console.error(`[kv][upstash] SET failed (${response.status}): ${text.slice(0, 200)}`);
49
+ }
45
50
  }
46
51
 
47
52
  async setWithTtl(key: string, value: string, ttl: number): Promise<void> {
48
- await fetch(
49
- `${this.baseUrl}/setex/${encodeURIComponent(key)}/${Math.max(1, ttl)}/${encodeURIComponent(value)}`,
50
- { method: "POST", headers: this.headers() },
51
- );
53
+ const response = await fetch(this.baseUrl, {
54
+ method: "POST",
55
+ headers: this.headers(),
56
+ body: JSON.stringify(["SETEX", key, Math.max(1, ttl), value]),
57
+ });
58
+ if (!response.ok) {
59
+ const text = await response.text().catch(() => "");
60
+ console.error(`[kv][upstash] SETEX failed (${response.status}): ${text.slice(0, 200)}`);
61
+ }
52
62
  }
53
63
  }
54
64
 
@@ -0,0 +1,181 @@
1
+ import { load as cheerioLoad, type CheerioAPI } from "cheerio";
2
+ import { defineTool, type ToolDefinition } from "@poncho-ai/sdk";
3
+
4
+ const SEARCH_UA =
5
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
6
+
7
+ const FETCH_TIMEOUT_MS = 15_000;
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // web_search — Brave Search HTML scraping (no API key)
11
+ // ---------------------------------------------------------------------------
12
+
13
+ interface SearchResult {
14
+ title: string;
15
+ url: string;
16
+ snippet: string;
17
+ }
18
+
19
+ async function braveSearch(query: string, maxResults: number): Promise<SearchResult[]> {
20
+ const url = `https://search.brave.com/search?q=${encodeURIComponent(query)}`;
21
+ const res = await fetch(url, {
22
+ headers: {
23
+ "User-Agent": SEARCH_UA,
24
+ Accept: "text/html,application/xhtml+xml",
25
+ "Accept-Language": "en-US,en;q=0.9",
26
+ },
27
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
28
+ });
29
+ if (!res.ok) {
30
+ throw new Error(`Search request failed (${res.status} ${res.statusText})`);
31
+ }
32
+ const html = await res.text();
33
+ return parseBraveResults(html, maxResults);
34
+ }
35
+
36
+ function parseBraveResults(html: string, max: number): SearchResult[] {
37
+ const $ = cheerioLoad(html);
38
+ const results: SearchResult[] = [];
39
+
40
+ $('div.snippet[data-type="web"]').each((_i, el) => {
41
+ if (results.length >= max) return false;
42
+
43
+ const $el = $(el);
44
+ const anchor = $el.find(".result-content a").first();
45
+ const href = anchor.attr("href") ?? "";
46
+ if (!href.startsWith("http")) return;
47
+
48
+ const title = $el.find(".title").first().text().trim();
49
+ const snippet = $el.find(".generic-snippet .content").first().text().trim();
50
+
51
+ if (title) {
52
+ results.push({ title, url: href, snippet });
53
+ }
54
+ });
55
+
56
+ return results;
57
+ }
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // web_fetch — fetch a URL and extract readable text via cheerio
61
+ // ---------------------------------------------------------------------------
62
+
63
+ const DEFAULT_MAX_LENGTH = 16_000;
64
+
65
+ function extractReadableText($: CheerioAPI, maxLength: number): { title: string; content: string } {
66
+ const title = $("title").first().text().trim();
67
+
68
+ $("script, style, noscript, nav, footer, header, aside, [role='navigation'], [role='banner'], [role='contentinfo']").remove();
69
+ $("svg, iframe, form, button, input, select, textarea").remove();
70
+
71
+ let root = $("article").first();
72
+ if (!root.length) root = $("main").first();
73
+ if (!root.length) root = $("[role='main']").first();
74
+ if (!root.length) root = $("body").first();
75
+
76
+ const text = root
77
+ .text()
78
+ .replace(/[ \t]+/g, " ")
79
+ .replace(/\n{3,}/g, "\n\n")
80
+ .trim();
81
+
82
+ const content =
83
+ text.length > maxLength ? text.slice(0, maxLength) + "\n…(truncated)" : text;
84
+
85
+ return { title, content };
86
+ }
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // Tool definitions
90
+ // ---------------------------------------------------------------------------
91
+
92
+ export const createSearchTools = (): ToolDefinition[] => [
93
+ defineTool({
94
+ name: "web_search",
95
+ description:
96
+ "Search the web and return a list of results (title, URL, snippet). " +
97
+ "Use this instead of opening a browser when you need to find information online.",
98
+ inputSchema: {
99
+ type: "object",
100
+ properties: {
101
+ query: {
102
+ type: "string",
103
+ description: "The search query",
104
+ },
105
+ max_results: {
106
+ type: "number",
107
+ description: "Maximum number of results to return (1-10, default 5)",
108
+ },
109
+ },
110
+ required: ["query"],
111
+ additionalProperties: false,
112
+ },
113
+ handler: async (input) => {
114
+ const query = typeof input.query === "string" ? input.query.trim() : "";
115
+ if (!query) {
116
+ return { error: "A non-empty query string is required." };
117
+ }
118
+ const max = Math.min(Math.max(Number(input.max_results) || 5, 1), 10);
119
+ try {
120
+ const results = await braveSearch(query, max);
121
+ if (results.length === 0) {
122
+ return { query, results: [], note: "No results found. Try rephrasing your query." };
123
+ }
124
+ return { query, results };
125
+ } catch (err) {
126
+ const msg = err instanceof Error ? err.message : String(err);
127
+ return {
128
+ error: `Search failed: ${msg}`,
129
+ hint: "The search provider may be rate-limiting requests. Try again shortly, or use browser tools as a fallback.",
130
+ };
131
+ }
132
+ },
133
+ }),
134
+
135
+ defineTool({
136
+ name: "web_fetch",
137
+ description:
138
+ "Fetch a web page and return its text content (HTML tags stripped). " +
139
+ "Useful for reading articles, documentation, or any web page without opening a browser.",
140
+ inputSchema: {
141
+ type: "object",
142
+ properties: {
143
+ url: {
144
+ type: "string",
145
+ description: "The URL to fetch",
146
+ },
147
+ max_length: {
148
+ type: "number",
149
+ description: `Maximum character length of returned content (default ${DEFAULT_MAX_LENGTH})`,
150
+ },
151
+ },
152
+ required: ["url"],
153
+ additionalProperties: false,
154
+ },
155
+ handler: async (input) => {
156
+ const url = typeof input.url === "string" ? input.url.trim() : "";
157
+ if (!url) {
158
+ return { error: 'A "url" string is required.' };
159
+ }
160
+ const maxLength = Math.max(Number(input.max_length) || DEFAULT_MAX_LENGTH, 1_000);
161
+
162
+ try {
163
+ const res = await fetch(url, {
164
+ headers: { "User-Agent": SEARCH_UA, Accept: "text/html,application/xhtml+xml" },
165
+ redirect: "follow",
166
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
167
+ });
168
+ if (!res.ok) {
169
+ return { url, status: res.status, error: res.statusText };
170
+ }
171
+ const html = await res.text();
172
+ const $ = cheerioLoad(html);
173
+ const { title, content } = extractReadableText($, maxLength);
174
+ return { url, status: res.status, title, content };
175
+ } catch (err) {
176
+ const msg = err instanceof Error ? err.message : String(err);
177
+ return { url, error: `Fetch failed: ${msg}` };
178
+ }
179
+ },
180
+ }),
181
+ ];
package/src/state.ts CHANGED
@@ -21,6 +21,15 @@ export interface StateStore {
21
21
  delete(runId: string): Promise<void>;
22
22
  }
23
23
 
24
+ export interface PendingSubagentResult {
25
+ subagentId: string;
26
+ task: string;
27
+ status: "completed" | "error" | "stopped";
28
+ result?: import("@poncho-ai/sdk").RunResult;
29
+ error?: import("@poncho-ai/sdk").AgentFailure;
30
+ timestamp: number;
31
+ }
32
+
24
33
  export interface Conversation {
25
34
  conversationId: string;
26
35
  title: string;
@@ -55,6 +64,13 @@ export interface Conversation {
55
64
  channelId: string;
56
65
  platformThreadId: string;
57
66
  };
67
+ pendingSubagentResults?: PendingSubagentResult[];
68
+ subagentCallbackCount?: number;
69
+ runningCallbackSince?: number;
70
+ lastActivityAt?: number;
71
+ /** Harness-internal message chain preserved across continuation runs.
72
+ * Cleared when a run completes without continuation. */
73
+ _continuationMessages?: Message[];
58
74
  createdAt: number;
59
75
  updatedAt: number;
60
76
  }
@@ -67,6 +83,7 @@ export interface ConversationStore {
67
83
  update(conversation: Conversation): Promise<void>;
68
84
  rename(conversationId: string, title: string): Promise<Conversation | undefined>;
69
85
  delete(conversationId: string): Promise<boolean>;
86
+ appendSubagentResult(conversationId: string, result: PendingSubagentResult): Promise<void>;
70
87
  }
71
88
 
72
89
  export type StateProviderName =
@@ -300,6 +317,14 @@ export class InMemoryConversationStore implements ConversationStore {
300
317
  async delete(conversationId: string): Promise<boolean> {
301
318
  return this.conversations.delete(conversationId);
302
319
  }
320
+
321
+ async appendSubagentResult(conversationId: string, result: PendingSubagentResult): Promise<void> {
322
+ const conversation = this.conversations.get(conversationId);
323
+ if (!conversation) return;
324
+ if (!conversation.pendingSubagentResults) conversation.pendingSubagentResults = [];
325
+ conversation.pendingSubagentResults.push(result);
326
+ conversation.updatedAt = Date.now();
327
+ }
303
328
  }
304
329
 
305
330
  export type ConversationSummary = {
@@ -572,6 +597,16 @@ class FileConversationStore implements ConversationStore {
572
597
  }
573
598
  return removed;
574
599
  }
600
+
601
+ async appendSubagentResult(conversationId: string, result: PendingSubagentResult): Promise<void> {
602
+ await this.ensureLoaded();
603
+ const conversation = await this.get(conversationId);
604
+ if (!conversation) return;
605
+ if (!conversation.pendingSubagentResults) conversation.pendingSubagentResults = [];
606
+ conversation.pendingSubagentResults.push(result);
607
+ conversation.updatedAt = Date.now();
608
+ await this.update(conversation);
609
+ }
575
610
  }
576
611
 
577
612
  type LocalStateFile = {
@@ -689,6 +724,7 @@ abstract class KeyValueConversationStoreBase implements ConversationStore {
689
724
  protected readonly ttl?: number;
690
725
  private readonly agentIdPromise: Promise<string>;
691
726
  private readonly ownerLocks = new Map<string, Promise<void>>();
727
+ private readonly appendLocks = new Map<string, Promise<void>>();
692
728
  protected readonly memoryFallback: InMemoryConversationStore;
693
729
 
694
730
  constructor(ttl: number | undefined, workingDir: string, agentId?: string) {
@@ -712,6 +748,19 @@ abstract class KeyValueConversationStoreBase implements ConversationStore {
712
748
  }
713
749
  }
714
750
 
751
+ private async withAppendLock(conversationId: string, task: () => Promise<void>): Promise<void> {
752
+ const prev = this.appendLocks.get(conversationId) ?? Promise.resolve();
753
+ const next = prev.then(task, task);
754
+ this.appendLocks.set(conversationId, next);
755
+ try {
756
+ await next;
757
+ } finally {
758
+ if (this.appendLocks.get(conversationId) === next) {
759
+ this.appendLocks.delete(conversationId);
760
+ }
761
+ }
762
+ }
763
+
715
764
  private async namespace(): Promise<string> {
716
765
  const agentId = await this.agentIdPromise;
717
766
  return `poncho:${STORAGE_SCHEMA_VERSION}:${slugifyStorageComponent(agentId)}`;
@@ -945,6 +994,17 @@ abstract class KeyValueConversationStoreBase implements ConversationStore {
945
994
  });
946
995
  return true;
947
996
  }
997
+
998
+ async appendSubagentResult(conversationId: string, result: PendingSubagentResult): Promise<void> {
999
+ await this.withAppendLock(conversationId, async () => {
1000
+ const conversation = await this.get(conversationId);
1001
+ if (!conversation) return;
1002
+ if (!conversation.pendingSubagentResults) conversation.pendingSubagentResults = [];
1003
+ conversation.pendingSubagentResults.push(result);
1004
+ conversation.updatedAt = Date.now();
1005
+ await this.update(conversation);
1006
+ });
1007
+ }
948
1008
  }
949
1009
 
950
1010
  class UpstashConversationStore extends KeyValueConversationStoreBase {
@@ -991,23 +1051,28 @@ class UpstashConversationStore extends KeyValueConversationStoreBase {
991
1051
  return (payload.result ?? []).map((v) => v ?? undefined);
992
1052
  },
993
1053
  set: async (key: string, value: string, ttl?: number) => {
994
- const endpoint =
995
- typeof ttl === "number"
996
- ? `${this.baseUrl}/setex/${encodeURIComponent(key)}/${Math.max(
997
- 1,
998
- ttl,
999
- )}/${encodeURIComponent(value)}`
1000
- : `${this.baseUrl}/set/${encodeURIComponent(key)}/${encodeURIComponent(value)}`;
1001
- await fetch(endpoint, {
1054
+ const command = typeof ttl === "number"
1055
+ ? ["SETEX", key, Math.max(1, ttl), value]
1056
+ : ["SET", key, value];
1057
+ const response = await fetch(this.baseUrl, {
1002
1058
  method: "POST",
1003
1059
  headers: this.headers(),
1060
+ body: JSON.stringify(command),
1004
1061
  });
1062
+ if (!response.ok) {
1063
+ const text = await response.text().catch(() => "");
1064
+ console.error(`[store][upstash] SET failed (${response.status}): ${text.slice(0, 200)}`);
1065
+ }
1005
1066
  },
1006
1067
  del: async (key: string) => {
1007
- await fetch(`${this.baseUrl}/del/${encodeURIComponent(key)}`, {
1068
+ const response = await fetch(`${this.baseUrl}/del/${encodeURIComponent(key)}`, {
1008
1069
  method: "POST",
1009
1070
  headers: this.headers(),
1010
1071
  });
1072
+ if (!response.ok) {
1073
+ const text = await response.text().catch(() => "");
1074
+ console.error(`[store][upstash] DEL failed (${response.status}): ${text.slice(0, 200)}`);
1075
+ }
1011
1076
  },
1012
1077
  };
1013
1078
  }
@@ -15,14 +15,18 @@ export interface SubagentSummary {
15
15
  messageCount: number;
16
16
  }
17
17
 
18
+ export interface SubagentSpawnResult {
19
+ subagentId: string;
20
+ }
21
+
18
22
  export interface SubagentManager {
19
23
  spawn(opts: {
20
24
  task: string;
21
25
  parentConversationId: string;
22
26
  ownerId: string;
23
- }): Promise<SubagentResult>;
27
+ }): Promise<SubagentSpawnResult>;
24
28
 
25
- sendMessage(subagentId: string, message: string): Promise<SubagentResult>;
29
+ sendMessage(subagentId: string, message: string): Promise<SubagentSpawnResult>;
26
30
 
27
31
  stop(subagentId: string): Promise<void>;
28
32
 
@@ -1,48 +1,18 @@
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
- };
1
+ import { defineTool, type ToolContext, type ToolDefinition } from "@poncho-ai/sdk";
2
+ import type { SubagentManager } from "./subagent-manager.js";
32
3
 
33
4
  export const createSubagentTools = (
34
5
  manager: SubagentManager,
35
- getConversationId: () => string | undefined,
36
- getOwnerId: () => string,
37
6
  ): ToolDefinition[] => [
38
7
  defineTool({
39
8
  name: "spawn_subagent",
40
9
  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" +
10
+ "Spawn a subagent to work on a task in the background. Returns immediately with a subagent ID. " +
11
+ "The subagent runs independently and its result will be delivered to you as a message in the " +
12
+ "conversation when it completes.\n\n" +
44
13
  "Guidelines:\n" +
45
- "- Use subagents to parallelize work: call spawn_subagent multiple times in one response for independent sub-tasks -- they run concurrently.\n" +
14
+ "- Spawn all needed subagents in a SINGLE response (they run concurrently), then end your turn with a brief message to the user.\n" +
15
+ "- Do NOT spawn more subagents in follow-up steps. Wait for results to be delivered before deciding if more work is needed.\n" +
46
16
  "- Prefer doing work yourself for simple or quick tasks. Spawn subagents for substantial, self-contained work.\n" +
47
17
  "- The subagent has no memory of your conversation -- write thorough, self-contained instructions in the task.",
48
18
  inputSchema: {
@@ -58,29 +28,32 @@ export const createSubagentTools = (
58
28
  required: ["task"],
59
29
  additionalProperties: false,
60
30
  },
61
- handler: async (input) => {
31
+ handler: async (input: Record<string, unknown>, context: ToolContext) => {
62
32
  const task = typeof input.task === "string" ? input.task : "";
63
33
  if (!task.trim()) {
64
34
  return { error: "task is required" };
65
35
  }
66
- const conversationId = getConversationId();
36
+ const conversationId = context.conversationId;
67
37
  if (!conversationId) {
68
38
  return { error: "no active conversation to spawn subagent from" };
69
39
  }
70
- const result = await manager.spawn({
40
+ const ownerId = typeof context.parameters.__ownerId === "string"
41
+ ? context.parameters.__ownerId
42
+ : "anonymous";
43
+ const { subagentId } = await manager.spawn({
71
44
  task: task.trim(),
72
45
  parentConversationId: conversationId,
73
- ownerId: getOwnerId(),
46
+ ownerId,
74
47
  });
75
- return summarizeResult(result);
48
+ return { subagentId, status: "running" };
76
49
  },
77
50
  }),
78
51
 
79
52
  defineTool({
80
53
  name: "message_subagent",
81
54
  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. " +
55
+ "Send a follow-up message to a completed or stopped subagent. The subagent restarts in the " +
56
+ "background and its result will be delivered to you as a message when it completes. " +
84
57
  "Only works when the subagent is not currently running.",
85
58
  inputSchema: {
86
59
  type: "object",
@@ -103,8 +76,8 @@ export const createSubagentTools = (
103
76
  if (!subagentId || !message.trim()) {
104
77
  return { error: "subagent_id and message are required" };
105
78
  }
106
- const result = await manager.sendMessage(subagentId, message.trim());
107
- return summarizeResult(result);
79
+ const { subagentId: id } = await manager.sendMessage(subagentId, message.trim());
80
+ return { subagentId: id, status: "running" };
108
81
  },
109
82
  }),
110
83
 
@@ -145,8 +118,8 @@ export const createSubagentTools = (
145
118
  properties: {},
146
119
  additionalProperties: false,
147
120
  },
148
- handler: async () => {
149
- const conversationId = getConversationId();
121
+ handler: async (_input: Record<string, unknown>, context: ToolContext) => {
122
+ const conversationId = context.conversationId;
150
123
  if (!conversationId) {
151
124
  return { error: "no active conversation" };
152
125
  }