@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/.turbo/turbo-build.log +5 -5
- package/CHANGELOG.md +25 -0
- package/dist/index.d.ts +59 -14
- package/dist/index.js +274 -29
- package/package.json +2 -2
- package/src/harness.ts +50 -25
- package/src/index.ts +2 -0
- package/src/latitude-capture.ts +3 -13
- package/src/mcp.ts +11 -1
- package/src/state.ts +123 -5
- package/src/subagent-manager.ts +30 -0
- package/src/subagent-tools.ts +160 -0
- package/test/mcp.test.ts +83 -0
- package/.turbo/turbo-lint.log +0 -6
- package/.turbo/turbo-test.log +0 -139
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
|
|
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
|
|
1662
|
-
//
|
|
1663
|
-
//
|
|
1664
|
-
// each next() call against the remaining time budget.
|
|
1685
|
+
// Stream full response — use 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
|
|
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
|
|
1720
|
+
let nextPart: IteratorResult<(typeof result.fullStream) extends AsyncIterable<infer T> ? T : never> | null;
|
|
1701
1721
|
if (timeout <= 0 && chunkCount > 0) {
|
|
1702
|
-
|
|
1703
|
-
nextChunk = await textIterator.next();
|
|
1722
|
+
nextPart = await fullStreamIterator.next();
|
|
1704
1723
|
} else {
|
|
1705
1724
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
1706
|
-
|
|
1707
|
-
|
|
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 (
|
|
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 (
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
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
|
-
|
|
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";
|
package/src/latitude-capture.ts
CHANGED
|
@@ -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
|
-
*
|
|
21
|
-
*
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
+
];
|