@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/.turbo/turbo-build.log +4 -4
- package/CHANGELOG.md +28 -0
- package/dist/index.d.ts +28 -8
- package/dist/index.js +265 -86
- package/package.json +3 -2
- package/src/config.ts +2 -0
- package/src/harness.ts +33 -22
- package/src/index.ts +1 -0
- package/src/kv-store.ts +18 -8
- package/src/search-tools.ts +181 -0
- package/src/state.ts +74 -9
- package/src/subagent-manager.ts +6 -2
- package/src/subagent-tools.ts +21 -48
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
|
|
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
|
|
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
|
-
|
|
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
|
|
1389
|
-
?
|
|
1390
|
-
:
|
|
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
|
-
|
|
43
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
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
|
}
|
package/src/subagent-manager.ts
CHANGED
|
@@ -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<
|
|
27
|
+
}): Promise<SubagentSpawnResult>;
|
|
24
28
|
|
|
25
|
-
sendMessage(subagentId: string, message: string): Promise<
|
|
29
|
+
sendMessage(subagentId: string, message: string): Promise<SubagentSpawnResult>;
|
|
26
30
|
|
|
27
31
|
stop(subagentId: string): Promise<void>;
|
|
28
32
|
|
package/src/subagent-tools.ts
CHANGED
|
@@ -1,48 +1,18 @@
|
|
|
1
|
-
import { defineTool, type
|
|
2
|
-
import type { SubagentManager
|
|
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
|
|
42
|
-
"
|
|
43
|
-
"
|
|
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
|
-
"-
|
|
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 =
|
|
36
|
+
const conversationId = context.conversationId;
|
|
67
37
|
if (!conversationId) {
|
|
68
38
|
return { error: "no active conversation to spawn subagent from" };
|
|
69
39
|
}
|
|
70
|
-
const
|
|
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
|
|
46
|
+
ownerId,
|
|
74
47
|
});
|
|
75
|
-
return
|
|
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
|
|
83
|
-
"
|
|
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
|
|
107
|
-
return
|
|
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 =
|
|
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
|
}
|