@poncho-ai/harness 0.59.15 → 0.59.17

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.
@@ -1,5 +1,5 @@
1
1
 
2
- > @poncho-ai/harness@0.59.15 build /home/runner/work/poncho-ai/poncho-ai/packages/harness
2
+ > @poncho-ai/harness@0.59.17 build /home/runner/work/poncho-ai/poncho-ai/packages/harness
3
3
  > node scripts/embed-docs.js && tsup src/index.ts --format esm --dts
4
4
 
5
5
  [embed-docs] Generated poncho-docs.ts with 4 topics
@@ -9,8 +9,8 @@
9
9
  CLI Target: es2022
10
10
  ESM Build start
11
11
  ESM dist/isolate-F2PPSUL6.js 53.82 KB
12
- ESM dist/index.js 570.16 KB
13
- ESM ⚡️ Build success in 242ms
12
+ ESM dist/index.js 571.82 KB
13
+ ESM ⚡️ Build success in 264ms
14
14
  DTS Build start
15
- DTS ⚡️ Build success in 6639ms
16
- DTS dist/index.d.ts 103.12 KB
15
+ DTS ⚡️ Build success in 7586ms
16
+ DTS dist/index.d.ts 104.01 KB
package/CHANGELOG.md CHANGED
@@ -1,5 +1,42 @@
1
1
  # @poncho-ai/harness
2
2
 
3
+ ## 0.59.17
4
+
5
+ ### Patch Changes
6
+
7
+ - [`963a0e3`](https://github.com/cesr/poncho-ai/commit/963a0e34b9f362ab21b7b8d94835f37713d4e5da) Thanks [@cesr](https://github.com/cesr)! - Browser config: support embedding apps that own per-tenant browser sessions
8
+ and the live viewport.
9
+ - `browser.sessionName` now overrides the browser session id (previously
10
+ always `poncho-${agentId}`). Lets a multi-tenant host isolate sessions
11
+ per user even when every user shares one agent definition.
12
+ - `browser.storagePersistence` lets the host supply its own
13
+ `{ save(json), load() }` for the browser storage state (cookies +
14
+ localStorage). When provided, the harness skips its built-in file-based
15
+ persistence — so state can live in an encrypted/DB-backed per-tenant store.
16
+ - `browser.hostManagedStreaming` makes the harness skip wiring
17
+ `onFrame`/`onStatus` during `run()`, so no `browser:frame` / `browser:status`
18
+ events are emitted into the agent event stream. The host subscribes to the
19
+ `BrowserSession` listeners directly and streams frames out-of-band (and can
20
+ keep the viewport interactive while the agent is idle).
21
+
22
+ ## 0.59.16
23
+
24
+ ### Patch Changes
25
+
26
+ - [`96bbf55`](https://github.com/cesr/poncho-ai/commit/96bbf5532d85de1ae0957f770758bf26b9c1e50b) Thanks [@cesr](https://github.com/cesr)! - Tighten the oversized-tool-result spill read-back path (follow-up to the
27
+ spill guard):
28
+ - The spill handle now exposes `toolResultId` / `toolCallId` explicitly, so
29
+ the model passes the right id to `get_tool_result_by_id` instead of guessing
30
+ from the path stem.
31
+ - `get_tool_result_by_id` now redirects when it hits a spill envelope: it
32
+ returns `{ spilled: true, path, totalChars }` pointing at the VFS file rather
33
+ than silently paging the ~6k-char envelope (which read as "I've got
34
+ everything"). The file is the source of truth; read it with bash.
35
+ - The handle's `note` is now format-aware: JSONL spills keep the line tools
36
+ (`sed`/`grep`/`jq` per line), but pretty-JSON spills steer to byte-offset
37
+ reads and `jq -r` to unescape — `wc -l`/`grep` mislead on JSON whose string
38
+ fields carry escaped newlines on one line.
39
+
3
40
  ## 0.59.15
4
41
 
5
42
  ### Patch Changes
package/dist/index.d.ts CHANGED
@@ -820,6 +820,21 @@ interface PonchoConfig extends McpConfig {
820
820
  /** Connect to an existing browser via CDP URL or port.
821
821
  * Mutually exclusive with `provider`. */
822
822
  cdpUrl?: string;
823
+ /** Host-supplied persistence for the browser's storage state
824
+ * (cookies + localStorage as a Playwright storageState JSON). When
825
+ * provided, the harness uses this instead of its built-in file-based
826
+ * persistence — letting an embedding app store session state in its
827
+ * own (e.g. encrypted, per-tenant) backend. */
828
+ storagePersistence?: {
829
+ save(json: string): Promise<void>;
830
+ load(): Promise<string | undefined>;
831
+ };
832
+ /** When true, the host owns the live viewport: the harness will NOT
833
+ * wire frame/status listeners during run() (so no `browser:frame` /
834
+ * `browser:status` events are emitted into the agent event stream).
835
+ * The host is expected to subscribe to the BrowserSession's
836
+ * `onFrame`/`onStatus` directly and stream frames out-of-band. */
837
+ hostManagedStreaming?: boolean;
823
838
  };
824
839
  }
825
840
  declare const resolveStateConfig: (config: PonchoConfig | undefined) => StateConfig | undefined;
package/dist/index.js CHANGED
@@ -5676,19 +5676,24 @@ var formatSpillPayload = (output) => {
5676
5676
  return { content: JSON.stringify(output ?? null, null, 2), format: "json" };
5677
5677
  };
5678
5678
  var buildSpillHandle = (opts) => {
5679
- const { toolName, path, format, serialized, records } = opts;
5679
+ const { toolName, toolCallId, path, format, serialized, records } = opts;
5680
5680
  const approxTokens = Math.ceil(serialized.length / 4);
5681
- const isArray = format === "jsonl";
5681
+ const bytesHint = `\`wc -c ${path}\` (size), \`head -c 4000 ${path}\`, \`tail -c +<N> ${path} | head -c 4000\` (read from byte N)`;
5682
+ const note = format === "jsonl" ? `Result too large to return inline (~${approxTokens.toLocaleString()} tokens, ${records} records, one JSON object per line). Read it with bash: ${bytesHint}, \`sed -n '1,5p' ${path}\`, \`grep -i <term> ${path}\`, or \`jq -c 'select(...)' ${path}\` per line. Do NOT cat/read_file the whole file (it re-overflows). Or re-run the tool with a narrower request.` : `Result too large to return inline (~${approxTokens.toLocaleString()} tokens, JSON). Its string fields are escaped onto single lines, so \`wc -l\`/\`grep\` MISLEAD \u2014 read by byte offset (${bytesHint}) or unescape first with \`jq -r '.stdout // .' ${path}\` (then pipe to wc -l / grep). Do NOT cat/read_file the whole file (it re-overflows). Or re-run the tool with a narrower request.`;
5682
5683
  return {
5683
5684
  __toolResultSpilled: true,
5684
5685
  tool: toolName,
5686
+ // Expose the id explicitly so the model passes the right value if it
5687
+ // reaches for get_tool_result_by_id (the path stem is NOT the id).
5688
+ toolResultId: toolCallId,
5689
+ toolCallId,
5685
5690
  path,
5686
5691
  format,
5687
5692
  ...records !== void 0 ? { records } : {},
5688
5693
  totalChars: serialized.length,
5689
5694
  approxTokens,
5690
5695
  preview: serialized.slice(0, SPILLED_PREVIEW_CHARS),
5691
- note: `This result was too large to return inline (~${approxTokens.toLocaleString()} tokens) and was saved to ${path}. Inspect it with bash \u2014 e.g. \`wc -l ${path}\`, \`head -c 4000 ${path}\`, ` + (isArray ? `\`sed -n '1,5p' ${path}\`, \`grep -i <term> ${path}\`, or \`jq\` per line` : `\`grep -i <term> ${path}\``) + `. Do NOT cat or read_file the whole file (it re-overflows). Or re-run the tool with a narrower request (fewer items / a filter / metadata only).`
5696
+ note
5692
5697
  };
5693
5698
  };
5694
5699
  var buildInlineTruncation = (toolName, serialized) => {
@@ -9773,6 +9778,21 @@ var AgentHarness = class _AgentHarness {
9773
9778
  error: `No archived tool result found for id "${toolResultId}" in this conversation.`
9774
9779
  };
9775
9780
  }
9781
+ try {
9782
+ const env = JSON.parse(record.payload);
9783
+ if (env && env.__toolResultSpilled === true && typeof env.path === "string") {
9784
+ return {
9785
+ toolResultId: record.toolResultId,
9786
+ toolName: record.toolName,
9787
+ toolCallId: record.toolCallId,
9788
+ spilled: true,
9789
+ path: env.path,
9790
+ totalChars: typeof env.totalChars === "number" ? env.totalChars : void 0,
9791
+ note: `This result was spilled to ${env.path} \u2014 the archive only holds a preview. Read the full payload there with bash (head -c / tail -c +<N> | head -c, or jq), NOT via get_tool_result_by_id.`
9792
+ };
9793
+ }
9794
+ } catch {
9795
+ }
9776
9796
  const offset = Math.max(0, Number(input.offset) || 0);
9777
9797
  const limit = Math.min(Math.max(Number(input.limit) || 6e3, 1), 2e4);
9778
9798
  const end = Math.min(record.payload.length, offset + limit);
@@ -9900,7 +9920,7 @@ var AgentHarness = class _AgentHarness {
9900
9920
  await vfs.mkdir(policy.dir, { recursive: true });
9901
9921
  await vfs.writeText(path, content);
9902
9922
  await this.pruneSpillDir(vfs, policy.dir, policy.keepLast);
9903
- return buildSpillHandle({ toolName, path, format, serialized, records });
9923
+ return buildSpillHandle({ toolName, toolCallId, path, format, serialized, records });
9904
9924
  } catch {
9905
9925
  }
9906
9926
  }
@@ -10631,10 +10651,13 @@ var AgentHarness = class _AgentHarness {
10631
10651
  this._browserMod = browserMod;
10632
10652
  const browserCfg = typeof config.browser === "object" ? { ...config.browser } : {};
10633
10653
  const agentId = this.parsedAgent?.frontmatter.id ?? this.parsedAgent?.frontmatter.name ?? "default";
10634
- const sessionId = `poncho-${agentId}`;
10635
- const storagePersistence = await this.buildBrowserStoragePersistence(config, sessionId);
10636
- if (storagePersistence) {
10637
- browserCfg.storagePersistence = storagePersistence;
10654
+ const sessionName = typeof config.browser === "object" ? config.browser.sessionName : void 0;
10655
+ const sessionId = sessionName ?? `poncho-${agentId}`;
10656
+ if (!browserCfg.storagePersistence) {
10657
+ const storagePersistence = await this.buildBrowserStoragePersistence(config, sessionId);
10658
+ if (storagePersistence) {
10659
+ browserCfg.storagePersistence = storagePersistence;
10660
+ }
10638
10661
  }
10639
10662
  const session = new browserMod.BrowserSession(sessionId, browserCfg);
10640
10663
  this._browserSession = session;
@@ -10962,7 +10985,8 @@ ${this.skillFingerprint}`;
10962
10985
  const browserEventQueue = [];
10963
10986
  const browserCleanups = [];
10964
10987
  const browserSession = this._browserSession;
10965
- if (browserSession) {
10988
+ const hostManagedStreaming = typeof this.loadedConfig?.browser === "object" && this.loadedConfig.browser.hostManagedStreaming === true;
10989
+ if (browserSession && !hostManagedStreaming) {
10966
10990
  browserCleanups.push(
10967
10991
  browserSession.onFrame(conversationId, makeBrowserFrameListener(browserEventQueue)),
10968
10992
  browserSession.onStatus(conversationId, makeBrowserStatusListener(browserEventQueue))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poncho-ai/harness",
3
- "version": "0.59.15",
3
+ "version": "0.59.17",
4
4
  "description": "Agent execution runtime - conversation loop, tool dispatch, streaming",
5
5
  "repository": {
6
6
  "type": "git",
package/src/config.ts CHANGED
@@ -294,6 +294,21 @@ export interface PonchoConfig extends McpConfig {
294
294
  /** Connect to an existing browser via CDP URL or port.
295
295
  * Mutually exclusive with `provider`. */
296
296
  cdpUrl?: string;
297
+ /** Host-supplied persistence for the browser's storage state
298
+ * (cookies + localStorage as a Playwright storageState JSON). When
299
+ * provided, the harness uses this instead of its built-in file-based
300
+ * persistence — letting an embedding app store session state in its
301
+ * own (e.g. encrypted, per-tenant) backend. */
302
+ storagePersistence?: {
303
+ save(json: string): Promise<void>;
304
+ load(): Promise<string | undefined>;
305
+ };
306
+ /** When true, the host owns the live viewport: the harness will NOT
307
+ * wire frame/status listeners during run() (so no `browser:frame` /
308
+ * `browser:status` events are emitted into the agent event stream).
309
+ * The host is expected to subscribe to the BrowserSession's
310
+ * `onFrame`/`onStatus` directly and stream frames out-of-band. */
311
+ hostManagedStreaming?: boolean;
297
312
  };
298
313
  }
299
314
 
package/src/harness.ts CHANGED
@@ -1068,6 +1068,30 @@ export class AgentHarness {
1068
1068
  error: `No archived tool result found for id "${toolResultId}" in this conversation.`,
1069
1069
  };
1070
1070
  }
1071
+ // Spill envelopes: the archived payload is the small handle, not the
1072
+ // data — the real payload lives in a VFS file. Paging the envelope here
1073
+ // would silently return ~6k chars and `hasMore:false`, reading as "I've
1074
+ // got everything". Redirect to the file (read it with bash) instead of
1075
+ // building a second read path the agent already has via bash/jq.
1076
+ try {
1077
+ const env = JSON.parse(record.payload) as Record<string, unknown>;
1078
+ if (env && env.__toolResultSpilled === true && typeof env.path === "string") {
1079
+ return {
1080
+ toolResultId: record.toolResultId,
1081
+ toolName: record.toolName,
1082
+ toolCallId: record.toolCallId,
1083
+ spilled: true,
1084
+ path: env.path,
1085
+ totalChars: typeof env.totalChars === "number" ? env.totalChars : undefined,
1086
+ note:
1087
+ `This result was spilled to ${env.path} — the archive only holds a preview. ` +
1088
+ `Read the full payload there with bash (head -c / tail -c +<N> | head -c, or ` +
1089
+ `jq), NOT via get_tool_result_by_id.`,
1090
+ };
1091
+ }
1092
+ } catch {
1093
+ // Not JSON / not an envelope — fall through to normal paging.
1094
+ }
1071
1095
  const offset = Math.max(0, Number(input.offset) || 0);
1072
1096
  const limit = Math.min(Math.max(Number(input.limit) || 6000, 1), 20_000);
1073
1097
  const end = Math.min(record.payload.length, offset + limit);
@@ -1219,7 +1243,7 @@ export class AgentHarness {
1219
1243
  await vfs.mkdir(policy.dir, { recursive: true });
1220
1244
  await vfs.writeText(path, content);
1221
1245
  await this.pruneSpillDir(vfs, policy.dir, policy.keepLast);
1222
- return buildSpillHandle({ toolName, path, format, serialized, records });
1246
+ return buildSpillHandle({ toolName, toolCallId, path, format, serialized, records });
1223
1247
  } catch {
1224
1248
  // Fall through to inline truncation below.
1225
1249
  }
@@ -2099,11 +2123,18 @@ export class AgentHarness {
2099
2123
  this._browserMod = browserMod;
2100
2124
  const browserCfg: Record<string, unknown> = typeof config.browser === "object" ? { ...config.browser } : {};
2101
2125
  const agentId = this.parsedAgent?.frontmatter.id ?? this.parsedAgent?.frontmatter.name ?? "default";
2102
- const sessionId = `poncho-${agentId}`;
2103
-
2104
- const storagePersistence = await this.buildBrowserStoragePersistence(config, sessionId);
2105
- if (storagePersistence) {
2106
- browserCfg.storagePersistence = storagePersistence;
2126
+ // Let an embedding app override the session id (e.g. to make it per-tenant
2127
+ // when many users share one agent definition). Falls back to the agent id.
2128
+ const sessionName = typeof config.browser === "object" ? config.browser.sessionName : undefined;
2129
+ const sessionId = sessionName ?? `poncho-${agentId}`;
2130
+
2131
+ // Only build the built-in file persistence when the host didn't supply its
2132
+ // own. A host-provided `storagePersistence` (e.g. encrypted/DB-backed) wins.
2133
+ if (!browserCfg.storagePersistence) {
2134
+ const storagePersistence = await this.buildBrowserStoragePersistence(config, sessionId);
2135
+ if (storagePersistence) {
2136
+ browserCfg.storagePersistence = storagePersistence;
2137
+ }
2107
2138
  }
2108
2139
 
2109
2140
  const session = new browserMod.BrowserSession(sessionId, browserCfg);
@@ -2553,7 +2584,14 @@ Code is wrapped in an async IIFE — use \`return\` to return a value to the too
2553
2584
  profileDir: string;
2554
2585
  isLaunched: boolean }
2555
2586
  | undefined;
2556
- if (browserSession) {
2587
+ // When the host owns the live viewport (`browser.hostManagedStreaming`),
2588
+ // it subscribes to onFrame/onStatus directly and streams frames out-of-band.
2589
+ // Skip the during-run wiring so frames don't double-emit into the agent
2590
+ // event stream (and don't flood any host-side event buffer).
2591
+ const hostManagedStreaming =
2592
+ typeof this.loadedConfig?.browser === "object" &&
2593
+ this.loadedConfig.browser.hostManagedStreaming === true;
2594
+ if (browserSession && !hostManagedStreaming) {
2557
2595
  browserCleanups.push(
2558
2596
  browserSession.onFrame(conversationId, makeBrowserFrameListener(browserEventQueue)),
2559
2597
  browserSession.onStatus(conversationId, makeBrowserStatusListener(browserEventQueue)),
@@ -80,32 +80,49 @@ export const formatSpillPayload = (
80
80
  * a file: a small preview plus the path and bash-readback instructions. */
81
81
  export const buildSpillHandle = (opts: {
82
82
  toolName: string;
83
+ toolCallId: string;
83
84
  path: string;
84
85
  format: "jsonl" | "json";
85
86
  serialized: string;
86
87
  records?: number;
87
88
  }): Record<string, unknown> => {
88
- const { toolName, path, format, serialized, records } = opts;
89
+ const { toolName, toolCallId, path, format, serialized, records } = opts;
89
90
  const approxTokens = Math.ceil(serialized.length / 4);
90
- const isArray = format === "jsonl";
91
+ // Byte-offset reads are correct for BOTH formats; line tools (`wc -l`,
92
+ // `grep`, `sed -n`) only behave for JSONL (one record per line). For pretty
93
+ // JSON the payload is a JSON document whose string fields (e.g. a command's
94
+ // stdout) carry escaped `\n` on a single line, so `wc -l`/`grep` mislead —
95
+ // unescape with `jq -r` first. Tailor the hint to the format so the model
96
+ // doesn't act on wrong line/match counts.
97
+ const bytesHint =
98
+ `\`wc -c ${path}\` (size), \`head -c 4000 ${path}\`, ` +
99
+ `\`tail -c +<N> ${path} | head -c 4000\` (read from byte N)`;
100
+ const note =
101
+ format === "jsonl"
102
+ ? `Result too large to return inline (~${approxTokens.toLocaleString()} tokens, ` +
103
+ `${records} records, one JSON object per line). Read it with bash: ${bytesHint}, ` +
104
+ `\`sed -n '1,5p' ${path}\`, \`grep -i <term> ${path}\`, or \`jq -c 'select(...)' ${path}\` ` +
105
+ `per line. Do NOT cat/read_file the whole file (it re-overflows). Or re-run the tool ` +
106
+ `with a narrower request.`
107
+ : `Result too large to return inline (~${approxTokens.toLocaleString()} tokens, JSON). ` +
108
+ `Its string fields are escaped onto single lines, so \`wc -l\`/\`grep\` MISLEAD — ` +
109
+ `read by byte offset (${bytesHint}) or unescape first with ` +
110
+ `\`jq -r '.stdout // .' ${path}\` (then pipe to wc -l / grep). Do NOT cat/read_file the ` +
111
+ `whole file (it re-overflows). Or re-run the tool with a narrower request.`;
91
112
  return {
92
113
  __toolResultSpilled: true,
93
114
  tool: toolName,
115
+ // Expose the id explicitly so the model passes the right value if it
116
+ // reaches for get_tool_result_by_id (the path stem is NOT the id).
117
+ toolResultId: toolCallId,
118
+ toolCallId,
94
119
  path,
95
120
  format,
96
121
  ...(records !== undefined ? { records } : {}),
97
122
  totalChars: serialized.length,
98
123
  approxTokens,
99
124
  preview: serialized.slice(0, SPILLED_PREVIEW_CHARS),
100
- note:
101
- `This result was too large to return inline (~${approxTokens.toLocaleString()} ` +
102
- `tokens) and was saved to ${path}. Inspect it with bash — e.g. ` +
103
- `\`wc -l ${path}\`, \`head -c 4000 ${path}\`, ` +
104
- (isArray
105
- ? `\`sed -n '1,5p' ${path}\`, \`grep -i <term> ${path}\`, or \`jq\` per line`
106
- : `\`grep -i <term> ${path}\``) +
107
- `. Do NOT cat or read_file the whole file (it re-overflows). Or re-run the ` +
108
- `tool with a narrower request (fewer items / a filter / metadata only).`,
125
+ note,
109
126
  };
110
127
  };
111
128
 
@@ -81,11 +81,12 @@ describe("formatSpillPayload", () => {
81
81
  });
82
82
 
83
83
  describe("buildSpillHandle", () => {
84
- it("returns a handle with path, format, records, preview, and bash instructions", () => {
84
+ it("returns a handle with path, format, records, preview, the call id, and bash instructions", () => {
85
85
  const serialized = "x".repeat(SPILLED_PREVIEW_CHARS + 50_000);
86
86
  const h = buildSpillHandle({
87
87
  toolName: "mcp_gmail_GMAIL_FETCH_EMAILS",
88
- path: "/tmp/tool-results/mcp_gmail_GMAIL_FETCH_EMAILS_tool_1.jsonl",
88
+ toolCallId: "toolu_01Ctij",
89
+ path: "/tmp/tool-results/mcp_gmail_GMAIL_FETCH_EMAILS_toolu_01Ctij.jsonl",
89
90
  format: "jsonl",
90
91
  serialized,
91
92
  records: 312,
@@ -94,20 +95,31 @@ describe("buildSpillHandle", () => {
94
95
  expect(h.records).toBe(312);
95
96
  expect(h.totalChars).toBe(serialized.length);
96
97
  expect((h.preview as string).length).toBe(SPILLED_PREVIEW_CHARS);
98
+ // Issue 1: the call id is exposed explicitly (not just embedded in the path).
99
+ expect(h.toolCallId).toBe("toolu_01Ctij");
100
+ expect(h.toolResultId).toBe("toolu_01Ctij");
97
101
  expect(String(h.note)).toContain("/tmp/tool-results/");
98
- expect(String(h.note)).toContain("jq"); // array → jsonl hint
99
102
  expect(String(h.note)).toContain("Do NOT cat");
103
+ // JSONL: line tools are valid (one record per line).
104
+ expect(String(h.note)).toContain("sed -n");
100
105
  });
101
106
 
102
- it("omits the array-only jq hint and records for json format", () => {
107
+ it("json format steers AWAY from line tools and toward byte-offset / jq (Issue 3)", () => {
103
108
  const h = buildSpillHandle({
104
- toolName: "t",
105
- path: "/tmp/tool-results/t_x.json",
109
+ toolName: "run_code",
110
+ toolCallId: "toolu_2",
111
+ path: "/tmp/tool-results/run_code_toolu_2.json",
106
112
  format: "json",
107
113
  serialized: "y".repeat(10),
108
114
  });
109
115
  expect(h.records).toBeUndefined();
110
- expect(String(h.note)).not.toContain("jq");
116
+ const note = String(h.note);
117
+ // It must warn that wc -l / grep mislead, and point at byte-offset + jq -r.
118
+ expect(note).toContain("MISLEAD");
119
+ expect(note).toContain("tail -c +");
120
+ expect(note).toContain("jq -r");
121
+ // It must NOT suggest sed-by-line for escaped-JSON.
122
+ expect(note).not.toContain("sed -n");
111
123
  });
112
124
  });
113
125