@poncho-ai/harness 0.35.0 → 0.36.1

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.
Files changed (49) hide show
  1. package/.turbo/turbo-build.log +12 -11
  2. package/CHANGELOG.md +25 -0
  3. package/dist/index.d.ts +485 -29
  4. package/dist/index.js +2839 -2114
  5. package/dist/isolate-TCWTUVG4.js +1532 -0
  6. package/package.json +23 -4
  7. package/scripts/migrate-to-engine.mjs +556 -0
  8. package/src/config.ts +106 -1
  9. package/src/harness.ts +226 -91
  10. package/src/index.ts +5 -0
  11. package/src/isolate/bindings.ts +206 -0
  12. package/src/isolate/bundler.ts +179 -0
  13. package/src/isolate/index.ts +10 -0
  14. package/src/isolate/polyfills.ts +796 -0
  15. package/src/isolate/run-code-tool.ts +220 -0
  16. package/src/isolate/runtime.ts +286 -0
  17. package/src/isolate/type-stubs.ts +196 -0
  18. package/src/memory.ts +129 -198
  19. package/src/reminder-store.ts +3 -237
  20. package/src/secrets-store.ts +2 -91
  21. package/src/state.ts +11 -1302
  22. package/src/storage/engine.ts +106 -0
  23. package/src/storage/index.ts +59 -0
  24. package/src/storage/memory-engine.ts +588 -0
  25. package/src/storage/postgres-engine.ts +139 -0
  26. package/src/storage/schema.ts +145 -0
  27. package/src/storage/sql-dialect.ts +963 -0
  28. package/src/storage/sqlite-engine.ts +99 -0
  29. package/src/storage/store-adapters.ts +100 -0
  30. package/src/todo-tools.ts +1 -136
  31. package/src/upload-store.ts +1 -0
  32. package/src/vfs/bash-manager.ts +120 -0
  33. package/src/vfs/bash-tool.ts +59 -0
  34. package/src/vfs/create-bash-fs.ts +32 -0
  35. package/src/vfs/edit-file-tool.ts +72 -0
  36. package/src/vfs/index.ts +5 -0
  37. package/src/vfs/poncho-fs-adapter.ts +267 -0
  38. package/src/vfs/protected-fs.ts +177 -0
  39. package/src/vfs/read-file-tool.ts +103 -0
  40. package/src/vfs/write-file-tool.ts +49 -0
  41. package/test/harness.test.ts +30 -36
  42. package/test/isolate-vfs.test.ts +453 -0
  43. package/test/isolate.test.ts +252 -0
  44. package/test/state.test.ts +4 -27
  45. package/test/storage-engine.test.ts +250 -0
  46. package/test/vfs.test.ts +242 -0
  47. package/.turbo/turbo-lint.log +0 -6
  48. package/.turbo/turbo-test.log +0 -11931
  49. package/src/kv-store.ts +0 -216
package/src/config.ts CHANGED
@@ -1,12 +1,13 @@
1
1
  import { access } from "node:fs/promises";
2
2
  import { resolve } from "node:path";
3
3
  import { createJiti } from "jiti";
4
+ import type { JsonSchema } from "@poncho-ai/sdk";
4
5
  import type { MemoryConfig } from "./memory.js";
5
6
  import type { McpConfig } from "./mcp.js";
6
7
  import type { StateConfig } from "./state.js";
7
8
 
8
9
  export interface StorageConfig {
9
- provider?: "local" | "memory" | "redis" | "upstash" | "dynamodb";
10
+ provider?: "local" | "memory" | "sqlite" | "postgresql" | "redis" | "upstash" | "dynamodb";
10
11
  urlEnv?: string;
11
12
  tokenEnv?: string;
12
13
  table?: string;
@@ -21,6 +22,10 @@ export interface StorageConfig {
21
22
  enabled?: boolean;
22
23
  maxRecallConversations?: number;
23
24
  };
25
+ limits?: {
26
+ maxFileSize?: number;
27
+ maxTotalStorage?: number;
28
+ };
24
29
  }
25
30
 
26
31
  export interface UploadsConfig {
@@ -68,6 +73,97 @@ export interface MessagingChannelConfig {
68
73
  allowedUserIds?: number[];
69
74
  }
70
75
 
76
+ export interface IsolateBinding {
77
+ description: string;
78
+ inputSchema: JsonSchema;
79
+ handler: (input: Record<string, unknown>) => Promise<unknown> | unknown;
80
+ }
81
+
82
+ /**
83
+ * Network access configuration for the bash sandbox (curl, wget).
84
+ * Network access is disabled by default — you must explicitly allow URLs.
85
+ */
86
+ export interface NetworkConfig {
87
+ /**
88
+ * List of allowed URL prefixes. Each entry must be a full origin (scheme + host),
89
+ * optionally followed by a path prefix.
90
+ *
91
+ * Examples:
92
+ * - `"https://api.example.com"` — allows all paths on this origin
93
+ * - `"https://api.example.com/v1/"` — allows only paths starting with /v1/
94
+ *
95
+ * Entries can be plain strings or objects with header transforms for credentials brokering:
96
+ * ```
97
+ * { url: "https://api.example.com", transform: [{ headers: { "Authorization": "Bearer ..." } }] }
98
+ * ```
99
+ */
100
+ allowedUrls?: (string | { url: string; transform?: { headers: Record<string, string> }[] })[];
101
+ /** Allowed HTTP methods. Defaults to `["GET", "HEAD"]`. */
102
+ allowedMethods?: ("GET" | "HEAD" | "POST" | "PUT" | "DELETE" | "PATCH" | "OPTIONS")[];
103
+ /** Bypass the allow-list and permit all URLs and methods. Only use in trusted environments. */
104
+ dangerouslyAllowAll?: boolean;
105
+ /** Maximum number of redirects to follow. Default: 20. */
106
+ maxRedirects?: number;
107
+ /** Request timeout in milliseconds. Default: 30000. */
108
+ timeoutMs?: number;
109
+ /** Maximum response body size in bytes. Default: 10MB. */
110
+ maxResponseSize?: number;
111
+ /** Reject URLs resolving to private/loopback IPs (SSRF protection). Default: false. */
112
+ denyPrivateRanges?: boolean;
113
+ }
114
+
115
+ export interface BashExecutionLimits {
116
+ /** Maximum function call/recursion depth. Default: 100. */
117
+ maxCallDepth?: number;
118
+ /** Maximum number of commands to execute. Default: 10000. */
119
+ maxCommandCount?: number;
120
+ /** Maximum loop iterations for while/for/until. Default: 10000. */
121
+ maxLoopIterations?: number;
122
+ /** Maximum total output size (stdout + stderr) in bytes. Default: 10MB. */
123
+ maxOutputSize?: number;
124
+ /** Maximum string length in bytes. Default: 10MB. */
125
+ maxStringLength?: number;
126
+ /** Maximum array elements. Default: 100000. */
127
+ maxArrayElements?: number;
128
+ }
129
+
130
+ export interface BashConfig {
131
+ /**
132
+ * Whitelist of allowed commands. When set, only these commands are available.
133
+ * Omit to allow all built-in commands.
134
+ *
135
+ * @example ["cat", "grep", "jq", "echo", "ls", "head", "tail", "wc", "sort"]
136
+ */
137
+ commands?: string[];
138
+ /** Execution limits to prevent runaway scripts. */
139
+ executionLimits?: BashExecutionLimits;
140
+ /** Enable python3/python commands in the sandbox. Default: false. */
141
+ python?: boolean;
142
+ /** Enable js-exec/node commands via QuickJS in the sandbox. Default: false. */
143
+ javascript?: boolean;
144
+ /** Environment variables injected into every bash session. */
145
+ env?: Record<string, string>;
146
+ }
147
+
148
+ export interface IsolateConfig {
149
+ /** V8 isolate memory limit in MB. Default: 128 */
150
+ memoryLimit?: number;
151
+ /** Execution timeout in ms. Default: 10000 */
152
+ timeLimit?: number;
153
+ /** Max combined stdout+stderr in bytes. Default: 65536 */
154
+ outputLimit?: number;
155
+ /** Max code input size in bytes. Default: 102400 (100KB) */
156
+ codeLimit?: number;
157
+ /** npm packages to bundle and make available via require() */
158
+ libraries?: string[];
159
+ /** External API access */
160
+ apis?: {
161
+ fetch?: { allowedDomains: string[] };
162
+ };
163
+ /** Builder-defined custom bindings injected into the isolate */
164
+ bindings?: Record<string, IsolateBinding>;
165
+ }
166
+
71
167
  export interface PonchoConfig extends McpConfig {
72
168
  harness?: string;
73
169
  messaging?: MessagingChannelConfig[];
@@ -142,6 +238,15 @@ export interface PonchoConfig extends McpConfig {
142
238
  tenantSecrets?: Record<string, string>;
143
239
  /** Set to `false` to disable the built-in web UI (headless / API-only mode). */
144
240
  webUi?: false;
241
+ /** Enable sandboxed V8 isolate code execution. */
242
+ isolate?: IsolateConfig;
243
+ /**
244
+ * Network access for sandboxed tools (bash curl/wget, isolate fetch).
245
+ * Disabled by default — you must explicitly allow URLs.
246
+ */
247
+ network?: NetworkConfig;
248
+ /** Bash sandbox configuration. */
249
+ bash?: BashConfig;
145
250
  /** Enable browser automation tools. Set `true` for defaults, or provide config. */
146
251
  browser?:
147
252
  | boolean
package/src/harness.ts CHANGED
@@ -14,10 +14,24 @@ import type {
14
14
  } from "@poncho-ai/sdk";
15
15
  import { defineTool, getTextContent } from "@poncho-ai/sdk";
16
16
  import type { UploadStore } from "./upload-store.js";
17
- import { PONCHO_UPLOAD_SCHEME, deriveUploadKey } from "./upload-store.js";
17
+ import { PONCHO_UPLOAD_SCHEME, VFS_SCHEME, deriveUploadKey } from "./upload-store.js";
18
+ import type { StorageEngine } from "./storage/engine.js";
19
+ import { createStorageEngine, type StorageProvider } from "./storage/index.js";
20
+ import {
21
+ createConversationStoreFromEngine,
22
+ createMemoryStoreFromEngine,
23
+ createTodoStoreFromEngine,
24
+ createReminderStoreFromEngine,
25
+ } from "./storage/store-adapters.js";
26
+ import { BashEnvironmentManager } from "./vfs/bash-manager.js";
27
+ import { createBashTool } from "./vfs/bash-tool.js";
28
+ import { createReadFileTool } from "./vfs/read-file-tool.js";
29
+ import { createEditFileTool } from "./vfs/edit-file-tool.js";
30
+ import { createWriteFileTool } from "./vfs/write-file-tool.js";
31
+ import { PonchoFsAdapter } from "./vfs/poncho-fs-adapter.js";
18
32
  import { parseAgentFile, parseAgentMarkdown, renderAgentPrompt, type ParsedAgent, type AgentFrontmatter } from "./agent-parser.js";
19
33
  import { loadPonchoConfig, resolveMemoryConfig, resolveStateConfig, type PonchoConfig, type ToolAccess, type BuiltInToolToggles } from "./config.js";
20
- import { createDefaultTools, createDeleteDirectoryTool, createDeleteTool, createEditTool, createWriteTool, ponchoDocsTool } from "./default-tools.js";
34
+ import { ponchoDocsTool } from "./default-tools.js";
21
35
  import {
22
36
  createMemoryStore,
23
37
  createMemoryTools,
@@ -612,14 +626,14 @@ function extractMediaFromToolOutput(output: unknown): {
612
626
  obj.type === "file" &&
613
627
  typeof obj.data === "string" &&
614
628
  typeof obj.mediaType === "string" &&
615
- (obj.mediaType as string).startsWith("image/")
629
+ ((obj.mediaType as string).startsWith("image/") || obj.mediaType === "application/pdf")
616
630
  ) {
617
631
  mediaItems.push({
618
632
  type: "media",
619
633
  data: obj.data as string,
620
634
  mediaType: obj.mediaType as string,
621
635
  });
622
- return { type: "file", mediaType: obj.mediaType, filename: obj.filename ?? "image", _stripped: true };
636
+ return { type: "file", mediaType: obj.mediaType, filename: obj.filename ?? "file", _stripped: true };
623
637
  }
624
638
  const out: Record<string, unknown> = {};
625
639
  for (const [k, v] of Object.entries(obj)) out[k] = walk(v);
@@ -667,6 +681,11 @@ export class AgentHarness {
667
681
  private subagentManager?: SubagentManager;
668
682
  private readonly archivedToolResultsByConversation = new Map<string, Record<string, ArchivedToolResult>>();
669
683
 
684
+ /** Unified storage engine (replaces individual KV-backed stores). */
685
+ storageEngine?: StorageEngine;
686
+ /** Bash environment manager (creates per-tenant bash instances). */
687
+ private bashManager?: BashEnvironmentManager;
688
+
670
689
  private resolveToolAccess(toolName: string): ToolAccess {
671
690
  const tools = this.loadedConfig?.tools;
672
691
  if (!tools) return true;
@@ -723,23 +742,8 @@ export class AgentHarness {
723
742
  }
724
743
 
725
744
  private registerConfiguredBuiltInTools(config: PonchoConfig | undefined): void {
726
- for (const tool of createDefaultTools(this.workingDir)) {
727
- if (this.isToolEnabled(tool.name)) {
728
- this.registerIfMissing(tool);
729
- }
730
- }
731
- if (this.isToolEnabled("write_file")) {
732
- this.registerIfMissing(createWriteTool(this.workingDir));
733
- }
734
- if (this.isToolEnabled("edit_file")) {
735
- this.registerIfMissing(createEditTool(this.workingDir));
736
- }
737
- if (this.isToolEnabled("delete_file")) {
738
- this.registerIfMissing(createDeleteTool(this.workingDir));
739
- }
740
- if (this.isToolEnabled("delete_directory")) {
741
- this.registerIfMissing(createDeleteDirectoryTool(this.workingDir));
742
- }
745
+ // Old file tools (read_file, write_file, etc.) are replaced by the bash tool.
746
+ // Only register search tools, poncho_docs, and get_tool_result_by_id.
743
747
  for (const tool of createSearchTools()) {
744
748
  if (this.isToolEnabled(tool.name)) {
745
749
  this.registerIfMissing(tool);
@@ -804,6 +808,33 @@ export class AgentHarness {
804
808
  });
805
809
  }
806
810
 
811
+ private createVfsAccess(tenantId: string): NonNullable<ToolContext["vfs"]> {
812
+ const adapter = this.bashManager!.getAdapter(tenantId);
813
+ return {
814
+ readFile: (path: string) => adapter.readFileBuffer(path),
815
+ readText: (path: string) => adapter.readFile(path),
816
+ writeFile: (path: string, content: Uint8Array, mimeType?: string) =>
817
+ adapter.writeFile(path, content),
818
+ writeText: (path: string, content: string) =>
819
+ adapter.writeFile(path, content),
820
+ exists: (path: string) => adapter.exists(path),
821
+ stat: async (path: string) => {
822
+ const s = await adapter.stat(path);
823
+ return {
824
+ size: s.size,
825
+ isDirectory: s.isDirectory,
826
+ mimeType: undefined,
827
+ updatedAt: s.mtime.getTime(),
828
+ };
829
+ },
830
+ readdir: (path: string) => adapter.readdir(path),
831
+ mkdir: (path: string, options?: { recursive?: boolean }) =>
832
+ adapter.mkdir(path, options),
833
+ rm: (path: string, options?: { recursive?: boolean }) =>
834
+ adapter.rm(path, options),
835
+ };
836
+ }
837
+
807
838
  private shouldEnableWriteTool(): boolean {
808
839
  const override = process.env.PONCHO_FS_WRITE?.toLowerCase();
809
840
  if (override === "1" || override === "true" || override === "yes") {
@@ -990,11 +1021,15 @@ export class AgentHarness {
990
1021
 
991
1022
  let store = this.tenantMemoryStores.get(tenantId);
992
1023
  if (!store) {
993
- const agentId = this.parsedAgent?.frontmatter.id ?? this.parsedAgent?.frontmatter.name ?? "unknown";
994
- store = createMemoryStore(agentId, this.memoryConfig, {
995
- workingDir: this.workingDir,
996
- tenantId,
997
- });
1024
+ if (this.storageEngine) {
1025
+ store = createMemoryStoreFromEngine(this.storageEngine, tenantId);
1026
+ } else {
1027
+ const agentId = this.parsedAgent?.frontmatter.id ?? this.parsedAgent?.frontmatter.name ?? "unknown";
1028
+ store = createMemoryStore(agentId, this.memoryConfig, {
1029
+ workingDir: this.workingDir,
1030
+ tenantId,
1031
+ });
1032
+ }
998
1033
  this.tenantMemoryStores.set(tenantId, store);
999
1034
  // Evict oldest entries if cache grows too large
1000
1035
  if (this.tenantMemoryStores.size > 100) {
@@ -1385,13 +1420,55 @@ export class AgentHarness {
1385
1420
  this.registerSkillTools(skillMetadata);
1386
1421
  const agentId = this.parsedAgent.frontmatter.id ?? this.parsedAgent.frontmatter.name;
1387
1422
 
1423
+ // --- Unified Storage Engine ---
1424
+ const storageProvider = (config?.storage?.provider ?? "sqlite") as StorageProvider;
1425
+ const engine = createStorageEngine({
1426
+ provider: storageProvider,
1427
+ workingDir: this.workingDir,
1428
+ agentId,
1429
+ urlEnv: config?.storage?.urlEnv,
1430
+ });
1431
+ await engine.initialize();
1432
+ this.storageEngine = engine;
1433
+
1434
+ // --- Bash Environment Manager ---
1435
+ const maxFileSize = config?.storage?.limits?.maxFileSize ?? 100 * 1024 * 1024; // 100MB
1436
+ const maxTotalStorage = config?.storage?.limits?.maxTotalStorage ?? 1024 * 1024 * 1024; // 1GB
1437
+ const bashWorkingDir = this.environment === "production" ? null : this.workingDir;
1438
+ this.bashManager = new BashEnvironmentManager(
1439
+ engine,
1440
+ { maxFileSize, maxTotalStorage },
1441
+ bashWorkingDir,
1442
+ config?.bash,
1443
+ config?.network,
1444
+ );
1445
+ // Register VFS tools
1446
+ this.registerIfMissing(createBashTool(this.bashManager));
1447
+ this.registerIfMissing(createReadFileTool(engine));
1448
+ this.registerIfMissing(createEditFileTool(engine));
1449
+ this.registerIfMissing(createWriteFileTool(engine));
1450
+
1451
+ // --- Isolate (V8 sandboxed code execution) ---
1452
+ if (config?.isolate) {
1453
+ const { createRunCodeTool, buildRunCodeDescription, bundleLibraries } = await import("./isolate/index.js");
1454
+ let libraryPreamble: string | null = null;
1455
+ if (config.isolate.libraries?.length) {
1456
+ libraryPreamble = await bundleLibraries(config.isolate.libraries, this.workingDir);
1457
+ }
1458
+ const runCodeTool = createRunCodeTool({
1459
+ config: config.isolate,
1460
+ bashManager: this.bashManager,
1461
+ libraryPreamble,
1462
+ description: buildRunCodeDescription(config.isolate, !!config.network),
1463
+ network: config.network,
1464
+ });
1465
+ this.registerIfMissing(runCodeTool);
1466
+ }
1467
+
1468
+ // --- Memory (engine-backed or legacy fallback) ---
1388
1469
  this.memoryConfig = memoryConfig ?? undefined;
1389
1470
  if (memoryConfig?.enabled) {
1390
- this.memoryStore = createMemoryStore(
1391
- agentId,
1392
- memoryConfig,
1393
- { workingDir: this.workingDir },
1394
- );
1471
+ this.memoryStore = createMemoryStoreFromEngine(engine);
1395
1472
  this.dispatcher.registerMany(
1396
1473
  createMemoryTools(
1397
1474
  (ctx) => this.getMemoryStore(ctx.tenantId) ?? this.memoryStore!,
@@ -1400,16 +1477,17 @@ export class AgentHarness {
1400
1477
  );
1401
1478
  }
1402
1479
 
1403
- const stateConfig = resolveStateConfig(config);
1404
- this.todoStore = createTodoStore(agentId, stateConfig, { workingDir: this.workingDir });
1480
+ // --- Todos (engine-backed) ---
1481
+ this.todoStore = createTodoStoreFromEngine(engine);
1405
1482
  for (const tool of createTodoTools(this.todoStore)) {
1406
1483
  if (this.isToolEnabled(tool.name)) {
1407
1484
  this.registerIfMissing(tool);
1408
1485
  }
1409
1486
  }
1410
1487
 
1488
+ // --- Reminders (engine-backed) ---
1411
1489
  if (config?.reminders?.enabled) {
1412
- this.reminderStore = createReminderStore(agentId, stateConfig, { workingDir: this.workingDir });
1490
+ this.reminderStore = createReminderStoreFromEngine(engine);
1413
1491
  for (const tool of createReminderTools(this.reminderStore)) {
1414
1492
  if (this.isToolEnabled(tool.name)) {
1415
1493
  this.registerIfMissing(tool);
@@ -1427,6 +1505,7 @@ export class AgentHarness {
1427
1505
  }
1428
1506
 
1429
1507
  // Secrets store for per-tenant env var overrides
1508
+ const stateConfig = resolveStateConfig(config);
1430
1509
  const authTokenEnv = config?.auth?.tokenEnv ?? "PONCHO_AUTH_TOKEN";
1431
1510
  const authToken = process.env[authTokenEnv];
1432
1511
  if (authToken) {
@@ -1487,60 +1566,22 @@ export class AgentHarness {
1487
1566
  };
1488
1567
  }
1489
1568
 
1490
- if (provider === "upstash") {
1491
- const urlEnv = config.storage?.urlEnv ?? (process.env.UPSTASH_REDIS_REST_URL ? "UPSTASH_REDIS_REST_URL" : "KV_REST_API_URL");
1492
- const tokenEnv = config.storage?.tokenEnv ?? (process.env.UPSTASH_REDIS_REST_TOKEN ? "UPSTASH_REDIS_REST_TOKEN" : "KV_REST_API_TOKEN");
1493
- const baseUrl = (process.env[urlEnv] ?? "").replace(/\/+$/, "");
1494
- const token = process.env[tokenEnv] ?? "";
1495
- if (!baseUrl || !token) return undefined;
1496
- const headers = { Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
1497
- return {
1498
- async save(json: string) {
1499
- await fetch(`${baseUrl}/set/${encodeURIComponent(stateKey)}/${encodeURIComponent(json)}`, { method: "POST", headers });
1500
- },
1501
- async load() {
1502
- const res = await fetch(`${baseUrl}/get/${encodeURIComponent(stateKey)}`, { headers });
1503
- if (!res.ok) return undefined;
1504
- const body = await res.json() as { result?: string | null };
1505
- return body.result ?? undefined;
1506
- },
1507
- };
1508
- }
1509
-
1510
- if (provider === "redis") {
1511
- const urlEnv = config.storage?.urlEnv ?? "REDIS_URL";
1512
- const url = process.env[urlEnv] ?? "";
1513
- if (!url) return undefined;
1514
- let clientPromise: Promise<{ get(k: string): Promise<string | null>; set(k: string, v: string): Promise<unknown> } | undefined> | undefined;
1515
- const getClient = () => {
1516
- if (!clientPromise) {
1517
- clientPromise = (async () => {
1518
- try {
1519
- const mod = (await import("redis")) as unknown as {
1520
- createClient: (opts: { url: string }) => {
1521
- connect(): Promise<unknown>;
1522
- get(k: string): Promise<string | null>;
1523
- set(k: string, v: string): Promise<unknown>;
1524
- };
1525
- };
1526
- const c = mod.createClient({ url });
1527
- await c.connect();
1528
- return c;
1529
- } catch { return undefined; }
1530
- })();
1531
- }
1532
- return clientPromise;
1533
- };
1569
+ // For sqlite, postgresql, and all other providers: use local file persistence
1570
+ // (same as "local" above). The old upstash/redis branches have been removed.
1571
+ if (provider === "sqlite" || provider === "postgresql") {
1572
+ const { resolve: pathResolve } = await import("node:path");
1573
+ const { homedir: home } = await import("node:os");
1574
+ const stateDir = pathResolve(home(), ".poncho", "browser-state");
1575
+ const filePath = pathResolve(stateDir, `${sessionId}.json`);
1534
1576
  return {
1535
1577
  async save(json: string) {
1536
- const c = await getClient();
1537
- if (c) await c.set(stateKey, json);
1578
+ const { mkdir, writeFile } = await import("node:fs/promises");
1579
+ await mkdir(stateDir, { recursive: true });
1580
+ await writeFile(filePath, json, "utf8");
1538
1581
  },
1539
1582
  async load() {
1540
- const c = await getClient();
1541
- if (!c) return undefined;
1542
- const val = await c.get(stateKey);
1543
- return val ?? undefined;
1583
+ const { readFile } = await import("node:fs/promises");
1584
+ try { return await readFile(filePath, "utf8"); } catch { return undefined; }
1544
1585
  },
1545
1586
  };
1546
1587
  }
@@ -1632,12 +1673,20 @@ export class AgentHarness {
1632
1673
  this.otlpTracerProvider = undefined;
1633
1674
  }
1634
1675
  this.hasOtlpExporter = false;
1676
+
1677
+ // Cleanup bash environments and storage engine
1678
+ this.bashManager?.destroyAll();
1679
+ await this.storageEngine?.close();
1635
1680
  }
1636
1681
 
1637
1682
  listTools(): ToolDefinition[] {
1638
1683
  return this.dispatcher.list();
1639
1684
  }
1640
1685
 
1686
+ listSkills(): Array<{ name: string; description: string }> {
1687
+ return this.loadedSkills.map((s) => ({ name: s.name, description: s.description }));
1688
+ }
1689
+
1641
1690
  /**
1642
1691
  * Wraps the run() generator with an OTel root span (invoke_agent) so all
1643
1692
  * child spans (LLM calls via AI SDK, tool execution) group under one trace.
@@ -1818,11 +1867,56 @@ ${boundedMainMemory.trim()}`
1818
1867
  ? `\n\n## Open Tasks\n\n${openTodos.map((t) => `- [${t.status === "in_progress" ? "IN PROGRESS" : "PENDING"}] ${t.content} (id: ${t.id})`).join("\n")}`
1819
1868
  : "";
1820
1869
 
1870
+ const fsContext = this.bashManager
1871
+ ? `\n\n## Filesystem
1872
+
1873
+ You have a persistent virtual filesystem at \`/\`. Files you create are durable across conversations.
1874
+ Use the \`bash\` tool for all file operations (cat, echo, grep, awk, jq, sed, find, etc.).
1875
+
1876
+ Filesystem layout:
1877
+ - \`/\` — your working directory (persistent, database-backed)${
1878
+ this.environment !== "production"
1879
+ ? `\n- \`/project/\` — the project source code (read-write in dev; protected paths like .env, .git/ are blocked)`
1880
+ : ""
1881
+ }
1882
+
1883
+ Examples:${
1884
+ this.environment !== "production"
1885
+ ? `\n- Read a project file: \`cat /project/src/index.ts\``
1886
+ : ""
1887
+ }
1888
+ - Write a working file: \`echo "data" > /notes.txt\`
1889
+ - Process data: \`cat /data.csv | awk -F, '{print $2}' | sort | uniq -c\`
1890
+
1891
+ Files in the VFS are accessible to the user via \`/api/vfs/{path}\`. For example, a file at \`/downloads/report.pdf\` can be linked as \`/api/vfs/downloads/report.pdf\`. Use this to share downloadable files with the user.`
1892
+ : "";
1893
+
1894
+ // Isolate context (code execution guidance + type stubs)
1895
+ let isolateContext = "";
1896
+ if (this.loadedConfig?.isolate && this.dispatcher.get("run_code")) {
1897
+ const { generateIsolateTypeStubs } = await import("./isolate/index.js");
1898
+ const typeStubs = generateIsolateTypeStubs(this.loadedConfig.isolate);
1899
+ isolateContext = `\n\n## Code Execution
1900
+
1901
+ You have a \`run_code\` tool for executing JavaScript/TypeScript in a sandboxed V8 isolate.
1902
+
1903
+ **When to use \`run_code\` vs \`bash\`:**
1904
+ - \`bash\`: file manipulation, text processing with unix tools, shell pipelines
1905
+ - \`run_code\`: complex data processing, structured data, npm libraries, multi-step logic, binary file generation
1906
+
1907
+ **API reference (available inside the isolate):**
1908
+ \`\`\`typescript
1909
+ ${typeStubs}
1910
+ \`\`\`
1911
+
1912
+ Code is wrapped in an async IIFE — use \`return\` to return a value to the tool result.`;
1913
+ }
1914
+
1821
1915
  const buildSystemPrompt = (): string => {
1822
1916
  const agentPrompt = renderCurrentAgentPrompt();
1823
1917
  const promptWithSkills = this.skillContextWindow
1824
- ? `${agentPrompt}${developmentContext}\n\n${this.skillContextWindow}${browserContext}`
1825
- : `${agentPrompt}${developmentContext}${browserContext}`;
1918
+ ? `${agentPrompt}${developmentContext}\n\n${this.skillContextWindow}${browserContext}${fsContext}${isolateContext}`
1919
+ : `${agentPrompt}${developmentContext}${browserContext}${fsContext}${isolateContext}`;
1826
1920
  const timeContext = this.reminderStore
1827
1921
  ? `\n\nCurrent UTC time: ${new Date().toISOString()}`
1828
1922
  : "";
@@ -2025,9 +2119,37 @@ ${boundedMainMemory.trim()}`
2025
2119
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
2026
2120
  const rich = (meta as any)?._richToolResults as unknown[] | undefined;
2027
2121
  if (rich && rich.length > 0) {
2028
- // The rich array already conforms to the AI SDK ToolContent shape
2029
- // (tool-result parts with multi-part content outputs). Cast
2030
- // through `any` because the exact generic types are internal.
2122
+ // Resolve any vfs:// references in media items before sending
2123
+ // to the model. This keeps conversation history lightweight
2124
+ // (only stores the reference) while materializing the actual
2125
+ // bytes on demand at model-request time.
2126
+ if (this.storageEngine) {
2127
+ const tid = input.tenantId ?? "__default__";
2128
+ for (const part of rich) {
2129
+ const p = part as Record<string, unknown>;
2130
+ if (p.output && typeof p.output === "object") {
2131
+ const out = p.output as Record<string, unknown>;
2132
+ if (Array.isArray(out.value)) {
2133
+ for (let i = 0; i < out.value.length; i++) {
2134
+ const item = out.value[i] as Record<string, unknown>;
2135
+ if (
2136
+ item.type === "media" &&
2137
+ typeof item.data === "string" &&
2138
+ (item.data as string).startsWith(VFS_SCHEME)
2139
+ ) {
2140
+ try {
2141
+ const vfsPath = (item.data as string).slice(VFS_SCHEME.length);
2142
+ const buf = await this.storageEngine.vfs.readFile(tid, vfsPath);
2143
+ item.data = Buffer.from(buf).toString("base64");
2144
+ } catch {
2145
+ // File no longer available; leave as-is
2146
+ }
2147
+ }
2148
+ }
2149
+ }
2150
+ }
2151
+ }
2152
+ }
2031
2153
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
2032
2154
  return [{ role: "tool" as const, content: rich as any }];
2033
2155
  }
@@ -2181,7 +2303,11 @@ ${boundedMainMemory.trim()}`
2181
2303
  if (!isSupportedImage && !isSupportedFile && isTextBasedMime(part.mediaType)) {
2182
2304
  let textContent: string;
2183
2305
  try {
2184
- if (part.data.startsWith(PONCHO_UPLOAD_SCHEME) && this.uploadStore) {
2306
+ if (part.data.startsWith(VFS_SCHEME) && this.storageEngine) {
2307
+ const vfsPath = part.data.slice(VFS_SCHEME.length);
2308
+ const buf = await this.storageEngine.vfs.readFile(input.tenantId ?? "__default__", vfsPath);
2309
+ textContent = Buffer.from(buf).toString("utf8");
2310
+ } else if (part.data.startsWith(PONCHO_UPLOAD_SCHEME) && this.uploadStore) {
2185
2311
  const buf = await this.uploadStore.get(part.data);
2186
2312
  textContent = buf.toString("utf8");
2187
2313
  } else if (part.data.startsWith("https://") || part.data.startsWith("http://")) {
@@ -2210,7 +2336,11 @@ ${boundedMainMemory.trim()}`
2210
2336
  // fetch URLs itself (which fails for private blob stores).
2211
2337
  let resolvedData: string;
2212
2338
  try {
2213
- if (part.data.startsWith(PONCHO_UPLOAD_SCHEME) && this.uploadStore) {
2339
+ if (part.data.startsWith(VFS_SCHEME) && this.storageEngine) {
2340
+ const vfsPath = part.data.slice(VFS_SCHEME.length);
2341
+ const buf = await this.storageEngine.vfs.readFile(input.tenantId ?? "__default__", vfsPath);
2342
+ resolvedData = Buffer.from(buf).toString("base64");
2343
+ } else if (part.data.startsWith(PONCHO_UPLOAD_SCHEME) && this.uploadStore) {
2214
2344
  const buf = await this.uploadStore.get(part.data);
2215
2345
  resolvedData = buf.toString("base64");
2216
2346
  } else if (part.data.startsWith("https://") || part.data.startsWith("http://")) {
@@ -2649,6 +2779,9 @@ ${boundedMainMemory.trim()}`
2649
2779
  abortSignal: input.abortSignal,
2650
2780
  conversationId: input.conversationId,
2651
2781
  tenantId: input.tenantId,
2782
+ vfs: this.bashManager
2783
+ ? this.createVfsAccess(input.tenantId ?? "__default__")
2784
+ : undefined,
2652
2785
  };
2653
2786
 
2654
2787
  const toolResultsForModel: Array<{
@@ -2841,6 +2974,7 @@ ${boundedMainMemory.trim()}`
2841
2974
  return;
2842
2975
  }
2843
2976
 
2977
+ const callInputMap = new Map(approvedCalls.map((c) => [c.id, c.input]));
2844
2978
  for (const result of batchResults) {
2845
2979
  const span = toolSpans.get(result.callId);
2846
2980
  if (result.error) {
@@ -2894,6 +3028,7 @@ ${boundedMainMemory.trim()}`
2894
3028
  yield pushEvent({
2895
3029
  type: "tool:completed",
2896
3030
  tool: result.tool,
3031
+ input: callInputMap.get(result.callId),
2897
3032
  output: result.output,
2898
3033
  duration: now() - batchStart,
2899
3034
  outputTokenEstimate,
package/src/index.ts CHANGED
@@ -18,6 +18,11 @@ export * from "./state.js";
18
18
  export * from "./upload-store.js";
19
19
  export * from "./telemetry.js";
20
20
  export * from "./secrets-store.js";
21
+ export * from "./storage/index.js";
22
+ export * from "./storage/store-adapters.js";
23
+ export { PonchoFsAdapter } from "./vfs/poncho-fs-adapter.js";
24
+ export { BashEnvironmentManager } from "./vfs/bash-manager.js";
25
+ export { createBashTool } from "./vfs/bash-tool.js";
21
26
  export * from "./tenant-token.js";
22
27
  export * from "./tool-dispatcher.js";
23
28
  export * from "./subagent-manager.js";