@poncho-ai/harness 0.25.0 → 0.27.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poncho-ai/harness",
3
- "version": "0.25.0",
3
+ "version": "0.27.0",
4
4
  "description": "Agent execution runtime - conversation loop, tool dispatch, streaming",
5
5
  "repository": {
6
6
  "type": "git",
@@ -31,7 +31,7 @@
31
31
  "redis": "^5.10.0",
32
32
  "yaml": "^2.4.0",
33
33
  "zod": "^3.22.0",
34
- "@poncho-ai/sdk": "1.5.0"
34
+ "@poncho-ai/sdk": "1.6.0"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@types/mustache": "^4.2.6",
package/src/config.ts CHANGED
@@ -42,6 +42,10 @@ export type BuiltInToolToggles = {
42
42
  edit_file?: boolean;
43
43
  delete_file?: boolean;
44
44
  delete_directory?: boolean;
45
+ todo_list?: boolean;
46
+ todo_add?: boolean;
47
+ todo_update?: boolean;
48
+ todo_remove?: boolean;
45
49
  };
46
50
 
47
51
  export interface MessagingChannelConfig {
package/src/harness.ts CHANGED
@@ -16,13 +16,14 @@ import { getTextContent } from "@poncho-ai/sdk";
16
16
  import type { UploadStore } from "./upload-store.js";
17
17
  import { PONCHO_UPLOAD_SCHEME, deriveUploadKey } from "./upload-store.js";
18
18
  import { parseAgentFile, parseAgentMarkdown, renderAgentPrompt, type ParsedAgent, type AgentFrontmatter } from "./agent-parser.js";
19
- import { loadPonchoConfig, resolveMemoryConfig, type PonchoConfig, type ToolAccess, type BuiltInToolToggles } from "./config.js";
19
+ import { loadPonchoConfig, resolveMemoryConfig, resolveStateConfig, type PonchoConfig, type ToolAccess, type BuiltInToolToggles } from "./config.js";
20
20
  import { createDefaultTools, createDeleteDirectoryTool, createDeleteTool, createEditTool, createWriteTool, ponchoDocsTool } from "./default-tools.js";
21
21
  import {
22
22
  createMemoryStore,
23
23
  createMemoryTools,
24
24
  type MemoryStore,
25
25
  } from "./memory.js";
26
+ import { createTodoStore, createTodoTools, type TodoItem, type TodoStore } from "./todo-tools.js";
26
27
  import { LocalMcpBridge } from "./mcp.js";
27
28
  import { createModelProvider, getModelContextWindow, type ModelProviderFactory, type ProviderConfig } from "./model-factory.js";
28
29
  import { buildSkillContextWindow, loadSkillMetadata } from "./skill-context.js";
@@ -550,6 +551,7 @@ export class AgentHarness {
550
551
  readonly uploadStore?: UploadStore;
551
552
  private skillContextWindow = "";
552
553
  private memoryStore?: MemoryStore;
554
+ private todoStore?: TodoStore;
553
555
  private loadedConfig?: PonchoConfig;
554
556
  private loadedSkills: SkillMetadata[] = [];
555
557
  private skillFingerprint = "";
@@ -560,7 +562,7 @@ export class AgentHarness {
560
562
  private insideTelemetryCapture = false;
561
563
  private _browserSession?: unknown;
562
564
  private _browserMod?: {
563
- createBrowserTools: (getSession: () => unknown, getConversationId: () => string) => ToolDefinition[];
565
+ createBrowserTools: (getSession: () => unknown) => ToolDefinition[];
564
566
  BrowserSession: new (sessionId: string, config: Record<string, unknown>) => unknown;
565
567
  };
566
568
 
@@ -620,11 +622,7 @@ export class AgentHarness {
620
622
  setSubagentManager(manager: SubagentManager): void {
621
623
  this.subagentManager = manager;
622
624
  this.dispatcher.registerMany(
623
- createSubagentTools(
624
- manager,
625
- () => this._currentRunConversationId,
626
- () => this._currentRunOwnerId ?? "anonymous",
627
- ),
625
+ createSubagentTools(manager),
628
626
  );
629
627
  }
630
628
 
@@ -678,6 +676,11 @@ export class AgentHarness {
678
676
  return this.parsedAgent?.frontmatter;
679
677
  }
680
678
 
679
+ async getTodos(conversationId: string): Promise<TodoItem[]> {
680
+ if (!this.todoStore) return [];
681
+ return this.todoStore.get(conversationId);
682
+ }
683
+
681
684
  private listActiveSkills(): string[] {
682
685
  return [...this.activeSkillNames].sort();
683
686
  }
@@ -1008,8 +1011,9 @@ export class AgentHarness {
1008
1011
  this.skillContextWindow = buildSkillContextWindow(skillMetadata);
1009
1012
  this.skillFingerprint = this.buildSkillFingerprint(skillMetadata);
1010
1013
  this.registerSkillTools(skillMetadata);
1014
+ const agentId = this.parsedAgent.frontmatter.id ?? this.parsedAgent.frontmatter.name;
1015
+
1011
1016
  if (memoryConfig?.enabled) {
1012
- const agentId = this.parsedAgent.frontmatter.id ?? this.parsedAgent.frontmatter.name;
1013
1017
  this.memoryStore = createMemoryStore(
1014
1018
  agentId,
1015
1019
  memoryConfig,
@@ -1022,6 +1026,14 @@ export class AgentHarness {
1022
1026
  );
1023
1027
  }
1024
1028
 
1029
+ const stateConfig = resolveStateConfig(config);
1030
+ this.todoStore = createTodoStore(agentId, stateConfig, { workingDir: this.workingDir });
1031
+ for (const tool of createTodoTools(this.todoStore)) {
1032
+ if (this.isToolEnabled(tool.name)) {
1033
+ this.registerIfMissing(tool);
1034
+ }
1035
+ }
1036
+
1025
1037
  if (config?.browser) {
1026
1038
  await this.initBrowserTools(config)
1027
1039
  .catch((e) => {
@@ -1149,7 +1161,7 @@ export class AgentHarness {
1149
1161
  private async initBrowserTools(config: PonchoConfig): Promise<void> {
1150
1162
  const spec = ["@poncho-ai", "browser"].join("/");
1151
1163
  let browserMod: {
1152
- createBrowserTools: (getSession: () => unknown, getConversationId: () => string) => ToolDefinition[];
1164
+ createBrowserTools: (getSession: () => unknown) => ToolDefinition[];
1153
1165
  BrowserSession: new (sessionId: string, cfg?: Record<string, unknown>) => unknown;
1154
1166
  };
1155
1167
  try {
@@ -1193,7 +1205,6 @@ export class AgentHarness {
1193
1205
 
1194
1206
  const tools = browserMod.createBrowserTools(
1195
1207
  () => session,
1196
- () => this._currentRunConversationId ?? "__default__",
1197
1208
  );
1198
1209
  for (const tool of tools) {
1199
1210
  if (this.isToolEnabled(tool.name)) {
@@ -1202,10 +1213,6 @@ export class AgentHarness {
1202
1213
  }
1203
1214
  }
1204
1215
 
1205
- /** Conversation ID of the currently executing run (set during run, cleared after). */
1206
- private _currentRunConversationId?: string;
1207
- /** Owner ID of the currently executing run (used by subagent tools). */
1208
- private _currentRunOwnerId?: string;
1209
1216
 
1210
1217
  get browserSession(): unknown {
1211
1218
  return this._browserSession;
@@ -1353,13 +1360,6 @@ export class AgentHarness {
1353
1360
  await this.refreshAgentIfChanged();
1354
1361
  await this.refreshSkillsIfChanged();
1355
1362
 
1356
- // Track which conversation/owner this run belongs to so browser & subagent tools resolve correctly
1357
- this._currentRunConversationId = input.conversationId;
1358
- const ownerParam = input.parameters?.__ownerId;
1359
- if (typeof ownerParam === "string") {
1360
- this._currentRunOwnerId = ownerParam;
1361
- }
1362
-
1363
1363
  let agent = this.parsedAgent as ParsedAgent;
1364
1364
  const runId = `run_${randomUUID()}`;
1365
1365
  const start = now();
@@ -1369,9 +1369,9 @@ export class AgentHarness {
1369
1369
  ? 0 // no hard timeout in development unless explicitly configured
1370
1370
  : (configuredTimeout ?? 300) * 1000;
1371
1371
  const platformMaxDurationSec = Number(process.env.PONCHO_MAX_DURATION) || 0;
1372
- const softDeadlineMs = platformMaxDurationSec > 0
1373
- ? platformMaxDurationSec * 800
1374
- : 0;
1372
+ const softDeadlineMs = (input.disableSoftDeadline || platformMaxDurationSec <= 0)
1373
+ ? 0
1374
+ : platformMaxDurationSec * 800;
1375
1375
  const messages: Message[] = [...(input.messages ?? [])];
1376
1376
  const inputMessageCount = messages.length;
1377
1377
  const events: AgentEvent[] = [];
@@ -1525,6 +1525,18 @@ ${boundedMainMemory.trim()}`
1525
1525
  metadata: { timestamp: now(), id: randomUUID() },
1526
1526
  });
1527
1527
  }
1528
+ } else {
1529
+ // Continuation run (no explicit task). Some providers (Anthropic) require
1530
+ // the conversation to end with a user message. Inject a transient signal
1531
+ // that is sent to the LLM but never persisted to the conversation store.
1532
+ const lastMsg = messages[messages.length - 1];
1533
+ if (lastMsg && lastMsg.role !== "user") {
1534
+ messages.push({
1535
+ role: "user",
1536
+ 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.]",
1537
+ metadata: { timestamp: now(), id: randomUUID() },
1538
+ });
1539
+ }
1528
1540
  }
1529
1541
 
1530
1542
  let responseText = "";
@@ -1561,6 +1573,7 @@ ${boundedMainMemory.trim()}`
1561
1573
  tokens: { input: totalInputTokens, output: totalOutputTokens, cached: totalCachedTokens },
1562
1574
  duration: now() - start,
1563
1575
  continuation: true,
1576
+ continuationMessages: [...messages],
1564
1577
  maxSteps,
1565
1578
  };
1566
1579
  yield pushEvent({ type: "run:completed", runId, result });
@@ -1711,6 +1724,9 @@ ${boundedMainMemory.trim()}`
1711
1724
  } catch {
1712
1725
  // Not JSON, treat as regular assistant text.
1713
1726
  }
1727
+ if (!assistantText || assistantText.trim().length === 0) {
1728
+ return [];
1729
+ }
1714
1730
  return [{ role: "assistant" as const, content: assistantText }];
1715
1731
  }
1716
1732
 
@@ -2387,6 +2403,7 @@ ${boundedMainMemory.trim()}`
2387
2403
  tokens: { input: totalInputTokens, output: totalOutputTokens, cached: totalCachedTokens },
2388
2404
  duration: now() - start,
2389
2405
  continuation: true,
2406
+ continuationMessages: [...messages],
2390
2407
  maxSteps,
2391
2408
  };
2392
2409
  yield pushEvent({ type: "run:completed", runId, result });
@@ -0,0 +1,216 @@
1
+ import type { StateConfig } from "./state.js";
2
+
3
+ /**
4
+ * Minimal raw key-value interface shared by MemoryStore, TodoStore, and any
5
+ * future stores that sit on top of the same user-configured backend.
6
+ */
7
+ export interface RawKVStore {
8
+ get(key: string): Promise<string | undefined>;
9
+ set(key: string, value: string): Promise<void>;
10
+ setWithTtl(key: string, value: string, ttlSeconds: number): Promise<void>;
11
+ }
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Upstash
15
+ // ---------------------------------------------------------------------------
16
+
17
+ class UpstashKVStore implements RawKVStore {
18
+ private readonly baseUrl: string;
19
+ private readonly token: string;
20
+
21
+ constructor(baseUrl: string, token: string) {
22
+ this.baseUrl = baseUrl.replace(/\/+$/, "");
23
+ this.token = token;
24
+ }
25
+
26
+ private headers(): HeadersInit {
27
+ return { Authorization: `Bearer ${this.token}`, "Content-Type": "application/json" };
28
+ }
29
+
30
+ async get(key: string): Promise<string | undefined> {
31
+ const response = await fetch(`${this.baseUrl}/get/${encodeURIComponent(key)}`, {
32
+ method: "POST",
33
+ headers: this.headers(),
34
+ });
35
+ if (!response.ok) return undefined;
36
+ const payload = (await response.json()) as { result?: string | null };
37
+ return payload.result ?? undefined;
38
+ }
39
+
40
+ async set(key: string, value: string): Promise<void> {
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
+ }
50
+ }
51
+
52
+ async setWithTtl(key: string, value: string, ttl: number): Promise<void> {
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
+ }
62
+ }
63
+ }
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Redis
67
+ // ---------------------------------------------------------------------------
68
+
69
+ class RedisKVStore implements RawKVStore {
70
+ private readonly clientPromise: Promise<
71
+ | {
72
+ get: (key: string) => Promise<string | null>;
73
+ set: (key: string, value: string, options?: { EX?: number }) => Promise<unknown>;
74
+ }
75
+ | undefined
76
+ >;
77
+
78
+ constructor(url: string) {
79
+ this.clientPromise = (async () => {
80
+ try {
81
+ const redisModule = (await import("redis")) as unknown as {
82
+ createClient: (args: { url: string }) => {
83
+ connect: () => Promise<unknown>;
84
+ get: (key: string) => Promise<string | null>;
85
+ set: (key: string, value: string, options?: { EX?: number }) => Promise<unknown>;
86
+ };
87
+ };
88
+ const client = redisModule.createClient({ url });
89
+ await client.connect();
90
+ return client;
91
+ } catch {
92
+ return undefined;
93
+ }
94
+ })();
95
+ }
96
+
97
+ async get(key: string): Promise<string | undefined> {
98
+ const client = await this.clientPromise;
99
+ if (!client) throw new Error("Redis unavailable");
100
+ const value = await client.get(key);
101
+ return value ?? undefined;
102
+ }
103
+
104
+ async set(key: string, value: string): Promise<void> {
105
+ const client = await this.clientPromise;
106
+ if (!client) throw new Error("Redis unavailable");
107
+ await client.set(key, value);
108
+ }
109
+
110
+ async setWithTtl(key: string, value: string, ttl: number): Promise<void> {
111
+ const client = await this.clientPromise;
112
+ if (!client) throw new Error("Redis unavailable");
113
+ await client.set(key, value, { EX: Math.max(1, ttl) });
114
+ }
115
+ }
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // DynamoDB
119
+ // ---------------------------------------------------------------------------
120
+
121
+ class DynamoDbKVStore implements RawKVStore {
122
+ private readonly table: string;
123
+ private readonly clientPromise: Promise<
124
+ | {
125
+ send: (command: unknown) => Promise<unknown>;
126
+ GetItemCommand: new (input: unknown) => unknown;
127
+ PutItemCommand: new (input: unknown) => unknown;
128
+ }
129
+ | undefined
130
+ >;
131
+
132
+ constructor(table: string, region?: string) {
133
+ this.table = table;
134
+ this.clientPromise = (async () => {
135
+ try {
136
+ const module = (await import("@aws-sdk/client-dynamodb")) as {
137
+ DynamoDBClient: new (input: { region?: string }) => {
138
+ send: (command: unknown) => Promise<unknown>;
139
+ };
140
+ GetItemCommand: new (input: unknown) => unknown;
141
+ PutItemCommand: new (input: unknown) => unknown;
142
+ };
143
+ const client = new module.DynamoDBClient({ region });
144
+ return {
145
+ send: client.send.bind(client),
146
+ GetItemCommand: module.GetItemCommand,
147
+ PutItemCommand: module.PutItemCommand,
148
+ };
149
+ } catch {
150
+ return undefined;
151
+ }
152
+ })();
153
+ }
154
+
155
+ async get(key: string): Promise<string | undefined> {
156
+ const client = await this.clientPromise;
157
+ if (!client) throw new Error("DynamoDB unavailable");
158
+ const result = (await client.send(
159
+ new client.GetItemCommand({ TableName: this.table, Key: { runId: { S: key } } }),
160
+ )) as { Item?: { value?: { S?: string } } };
161
+ return result.Item?.value?.S;
162
+ }
163
+
164
+ async set(key: string, value: string): Promise<void> {
165
+ const client = await this.clientPromise;
166
+ if (!client) throw new Error("DynamoDB unavailable");
167
+ await client.send(
168
+ new client.PutItemCommand({
169
+ TableName: this.table,
170
+ Item: { runId: { S: key }, value: { S: value } },
171
+ }),
172
+ );
173
+ }
174
+
175
+ async setWithTtl(key: string, value: string, ttl: number): Promise<void> {
176
+ const client = await this.clientPromise;
177
+ if (!client) throw new Error("DynamoDB unavailable");
178
+ const ttlEpoch = Math.floor(Date.now() / 1000) + Math.max(1, ttl);
179
+ await client.send(
180
+ new client.PutItemCommand({
181
+ TableName: this.table,
182
+ Item: { runId: { S: key }, value: { S: value }, ttl: { N: String(ttlEpoch) } },
183
+ }),
184
+ );
185
+ }
186
+ }
187
+
188
+ // ---------------------------------------------------------------------------
189
+ // Factory — resolves the user's storage config into a RawKVStore, or
190
+ // undefined when the provider is "local" or "memory" (handled by callers).
191
+ // ---------------------------------------------------------------------------
192
+
193
+ export const createRawKVStore = (config?: StateConfig): RawKVStore | undefined => {
194
+ const provider = config?.provider ?? "local";
195
+
196
+ if (provider === "upstash") {
197
+ const urlEnv = config?.urlEnv ?? (process.env.UPSTASH_REDIS_REST_URL ? "UPSTASH_REDIS_REST_URL" : "KV_REST_API_URL");
198
+ const tokenEnv = config?.tokenEnv ?? (process.env.UPSTASH_REDIS_REST_TOKEN ? "UPSTASH_REDIS_REST_TOKEN" : "KV_REST_API_TOKEN");
199
+ const url = process.env[urlEnv] ?? "";
200
+ const token = process.env[tokenEnv] ?? "";
201
+ if (url && token) return new UpstashKVStore(url, token);
202
+ }
203
+
204
+ if (provider === "redis") {
205
+ const urlEnv = config?.urlEnv ?? "REDIS_URL";
206
+ const url = process.env[urlEnv] ?? "";
207
+ if (url) return new RedisKVStore(url);
208
+ }
209
+
210
+ if (provider === "dynamodb") {
211
+ const table = config?.table ?? process.env.PONCHO_DYNAMODB_TABLE ?? "";
212
+ if (table) return new DynamoDbKVStore(table, config?.region);
213
+ }
214
+
215
+ return undefined;
216
+ };