@poncho-ai/harness 0.40.1 → 0.41.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.
@@ -1,5 +1,5 @@
1
1
 
2
- > @poncho-ai/harness@0.40.1 build /home/runner/work/poncho-ai/poncho-ai/packages/harness
2
+ > @poncho-ai/harness@0.41.0 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
@@ -8,9 +8,9 @@
8
8
  CLI tsup v8.5.1
9
9
  CLI Target: es2022
10
10
  ESM Build start
11
- ESM dist/index.js 489.88 KB
11
+ ESM dist/index.js 493.42 KB
12
12
  ESM dist/isolate-VY35DGLM.js 49.43 KB
13
- ESM ⚡️ Build success in 205ms
13
+ ESM ⚡️ Build success in 238ms
14
14
  DTS Build start
15
- DTS ⚡️ Build success in 7664ms
16
- DTS dist/index.d.ts 75.12 KB
15
+ DTS ⚡️ Build success in 7385ms
16
+ DTS dist/index.d.ts 77.00 KB
package/CHANGELOG.md CHANGED
@@ -1,5 +1,91 @@
1
1
  # @poncho-ai/harness
2
2
 
3
+ ## 0.41.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#110](https://github.com/cesr/poncho-ai/pull/110) [`7d57a88`](https://github.com/cesr/poncho-ai/commit/7d57a88e55a49ec04de3dbd415b2440bb727e31f) Thanks [@cesr](https://github.com/cesr)! - harness: allow programmatic agent + storage injection (no AGENT.md required)
8
+
9
+ `HarnessOptions` gains two optional fields that let callers construct a
10
+ `Harness` without an `AGENT.md` on disk and without the
11
+ `ensureAgentIdentity` filesystem dance:
12
+ - `agentDefinition?: string | ParsedAgent` — raw markdown or a pre-parsed
13
+ agent. When provided, `initialize()` skips the `AGENT.md` read.
14
+ - `storageEngine?: StorageEngine` — pre-constructed engine; required
15
+ whenever `agentDefinition` is provided. The engine's `agentId` (now a
16
+ public readonly field on the `StorageEngine` interface) becomes the
17
+ source of truth for partitioning, and is mirrored onto
18
+ `parsedAgent.frontmatter.id` so existing downstream readers continue
19
+ to resolve correctly.
20
+
21
+ When neither field is provided, behaviour is unchanged: the harness
22
+ reads `AGENT.md` from `workingDir`, calls `ensureAgentIdentity`, and
23
+ constructs the `StorageEngine` internally.
24
+
25
+ `refreshAgentIfChanged()` short-circuits when an agent definition was
26
+ injected — callers who update an agent re-instantiate the harness
27
+ rather than relying on disk file watching.
28
+
29
+ This is the first of a small set of changes that lets `@poncho-ai/harness`
30
+ be embedded as a library by consumer SaaS apps where each user has
31
+ their own per-tenant agent state in a database, no filesystem layout.
32
+
33
+ - [#111](https://github.com/cesr/poncho-ai/pull/111) [`ac18616`](https://github.com/cesr/poncho-ai/commit/ac18616b864189c91d0957c72c537933497505f4) Thanks [@cesr](https://github.com/cesr)! - harness: allow programmatic `PonchoConfig` injection
34
+
35
+ `HarnessOptions` gains an optional `config?: PonchoConfig` field. When
36
+ provided, `initialize()` skips `loadPonchoConfig` (which imports
37
+ `poncho.config.js` from `workingDir`) and uses the supplied object
38
+ directly. Downstream resolvers (`resolveMemoryConfig`,
39
+ `resolveStateConfig`, etc.) run as today, so any validation/normalization
40
+ they perform applies to injected configs identically.
41
+
42
+ Behaviour is unchanged when the field is absent: the disk loader runs
43
+ as before.
44
+
45
+ This is part of a small series of changes that enables
46
+ `@poncho-ai/harness` to be embedded as a library by a consumer SaaS
47
+ where each user's agent configuration comes from a database row, not a
48
+ `poncho.config.js` on disk.
49
+
50
+ - [#112](https://github.com/cesr/poncho-ai/pull/112) [`c22416b`](https://github.com/cesr/poncho-ai/commit/c22416b3d4c4557277aeabf53e70877be6436e85) Thanks [@cesr](https://github.com/cesr)! - harness: cache MCP clients per `(serverName, tenantId)` instead of rebuilding per call
51
+
52
+ When a tenant resolves a different bearer token than the host's
53
+ `process.env` default for an MCP server, the per-call handler used to
54
+ construct a brand-new `StreamableHttpMcpRpcClient` on every tool call.
55
+ For builders this rarely triggered. For consumer/SaaS deployments where
56
+ **every** call resolves a different per-user token, every tool call
57
+ forced a fresh `initialize` round-trip — no session reuse, high
58
+ latency, and a behaviour the recently-added 404 session-retry can't
59
+ help with because there was nothing to retry.
60
+
61
+ `LocalMcpBridge` now keeps a `Map<key, { client, token, lastUsed }>`
62
+ keyed by `(serverName, tenantId)`. Lookups reuse the cached client when
63
+ the token is unchanged and the entry is within the configured idle TTL
64
+ (default 15 minutes). On token rotation or TTL expiry the entry is
65
+ evicted lazily and rebuilt. `stopLocalServers()` closes all cached
66
+ tenant clients alongside the server-default ones.
67
+
68
+ The TTL is configurable via a constructor option (`tenantClientTtlMs`)
69
+ for tests and tuning.
70
+
71
+ ### Patch Changes
72
+
73
+ - [#109](https://github.com/cesr/poncho-ai/pull/109) [`4b5d974`](https://github.com/cesr/poncho-ai/commit/4b5d974345733ac9e68f36201dff7e7d8a8f0327) Thanks [@cesr](https://github.com/cesr)! - harness: re-initialize MCP session on 404 instead of staying wedged
74
+
75
+ Streamable-HTTP MCP clients with session state (e.g. Arcade's gateway
76
+ for Gmail / Google Calendar) issue an `Mcp-Session-Id` on initialize
77
+ and expire it after some idle window. The bridge cached `sessionId`
78
+ and `initialized` in process memory and never reset them, so once the
79
+ server returned 404 for a stale session every subsequent tool call
80
+ also 404'd until the host process restarted. Long-lived deployments
81
+ (e.g. Railway) hit this; serverless platforms masked it because each
82
+ invocation re-initialized.
83
+
84
+ The client now treats `404` with a stored `sessionId` as a session
85
+ expiry signal: it clears the session, re-runs `initialize`, and
86
+ retries the request once. A 404 from initialize itself (no session
87
+ yet) is still treated as a hard endpoint failure with no retry.
88
+
3
89
  ## 0.40.1
4
90
 
5
91
  ### Patch Changes
package/dist/index.d.ts CHANGED
@@ -370,12 +370,26 @@ declare class LocalMcpBridge {
370
370
  private readonly unavailableServers;
371
371
  private readonly authFailedServers;
372
372
  private envResolver?;
373
+ /**
374
+ * Per-tenant MCP client cache. For consumer/SaaS deployments where every
375
+ * call resolves a different bearer token, building a fresh
376
+ * `StreamableHttpMcpRpcClient` per call would force a fresh `initialize`
377
+ * round-trip every time. We keep one client per `(serverName, tenantId)`
378
+ * with TTL-based idle eviction; on token rotation we evict the entry
379
+ * lazily and rebuild.
380
+ */
381
+ private readonly tenantClients;
382
+ /** Test/observability hook: bumped every time a new tenant client is constructed. */
383
+ tenantClientConstructions: number;
384
+ private readonly tenantClientTtlMs;
373
385
  /**
374
386
  * Set a resolver for per-tenant env vars (e.g. MCP auth tokens).
375
387
  * Called by the harness after creating the secrets store.
376
388
  */
377
389
  setEnvResolver(resolver: (tenantId: string | undefined, envName: string) => Promise<string | undefined>): void;
378
- constructor(config: McpConfig | undefined);
390
+ constructor(config: McpConfig | undefined, options?: {
391
+ tenantClientTtlMs?: number;
392
+ });
379
393
  private getServerName;
380
394
  private log;
381
395
  /** Set of servers where discovery was deferred (no default token, has env resolver). */
@@ -389,6 +403,7 @@ declare class LocalMcpBridge {
389
403
  private tryDeferredDiscovery;
390
404
  startLocalServers(): Promise<void>;
391
405
  stopLocalServers(): Promise<void>;
406
+ private getOrCreateTenantClient;
392
407
  listServers(): RemoteMcpServerConfig[];
393
408
  listRemoteServers(): RemoteMcpServerConfig[];
394
409
  checkRemoteConnectivity(): Promise<Array<{
@@ -806,6 +821,8 @@ interface VfsDirEntry {
806
821
  type: "file" | "directory" | "symlink";
807
822
  }
808
823
  interface StorageEngine {
824
+ /** Partition key: every read/write is scoped to this agent id. */
825
+ readonly agentId: string;
809
826
  /** Run migrations and prepare the storage backend. */
810
827
  initialize(): Promise<void>;
811
828
  /** Gracefully release resources. */
@@ -1007,6 +1024,27 @@ interface HarnessOptions {
1007
1024
  toolDefinitions?: ToolDefinition[];
1008
1025
  modelProvider?: ModelProviderFactory;
1009
1026
  uploadStore?: UploadStore;
1027
+ /**
1028
+ * Inject the agent definition directly instead of reading AGENT.md from
1029
+ * `workingDir`. Pass raw markdown (string) or a pre-parsed `ParsedAgent`.
1030
+ * When provided, `storageEngine` is also required — the engine's
1031
+ * `agentId` becomes the source of truth for partitioning, and the
1032
+ * filesystem identity dance (`ensureAgentIdentity`) is skipped.
1033
+ */
1034
+ agentDefinition?: string | ParsedAgent;
1035
+ /**
1036
+ * Pre-constructed storage engine. When provided, the harness will not
1037
+ * create one internally. The engine's `agentId` is used wherever the
1038
+ * harness today reads `parsedAgent.frontmatter.id`.
1039
+ */
1040
+ storageEngine?: StorageEngine;
1041
+ /**
1042
+ * Inject a `PonchoConfig` object directly instead of importing
1043
+ * `poncho.config.js` from `workingDir`. When provided, the disk-based
1044
+ * loader is skipped. Downstream resolvers (`resolveMemoryConfig`,
1045
+ * `resolveStateConfig`, etc.) run as today regardless of source.
1046
+ */
1047
+ config?: PonchoConfig;
1010
1048
  }
1011
1049
  interface HarnessRunOutput {
1012
1050
  runId: string;
@@ -1037,6 +1075,7 @@ declare class AgentHarness {
1037
1075
  reminderStore?: ReminderStore;
1038
1076
  secretsStore?: SecretsStore;
1039
1077
  private loadedConfig?;
1078
+ private readonly injectedConfig?;
1040
1079
  private loadedSkills;
1041
1080
  private skillFingerprint;
1042
1081
  private lastSkillRefreshAt;
@@ -1051,6 +1090,8 @@ declare class AgentHarness {
1051
1090
  private _browserMod?;
1052
1091
  private parsedAgent?;
1053
1092
  private agentFileFingerprint;
1093
+ private injectedAgentDefinition?;
1094
+ private injectedStorageEngine;
1054
1095
  private mcpBridge?;
1055
1096
  private subagentManager?;
1056
1097
  private readonly archivedToolResultsByConversation;
@@ -1279,7 +1320,7 @@ declare class TelemetryEmitter {
1279
1320
  }
1280
1321
 
1281
1322
  declare class InMemoryEngine implements StorageEngine {
1282
- private readonly agentId;
1323
+ readonly agentId: string;
1283
1324
  private convs;
1284
1325
  private mem;
1285
1326
  private todoData;
@@ -1394,7 +1435,7 @@ declare class ConversationEgressMeter {
1394
1435
  }
1395
1436
  declare abstract class SqlStorageEngine implements StorageEngine {
1396
1437
  protected readonly dialect: Dialect;
1397
- protected readonly agentId: string;
1438
+ readonly agentId: string;
1398
1439
  protected abstract readonly executor: QueryExecutor;
1399
1440
  protected readonly egressMeter: ConversationEgressMeter;
1400
1441
  constructor(dialect: Dialect, agentId: string);
package/dist/index.js CHANGED
@@ -5911,6 +5911,11 @@ var McpHttpError = class extends Error {
5911
5911
  this.status = status;
5912
5912
  }
5913
5913
  };
5914
+ var McpSessionExpiredError = class extends Error {
5915
+ constructor() {
5916
+ super("MCP session expired");
5917
+ }
5918
+ };
5914
5919
  var StreamableHttpMcpRpcClient = class {
5915
5920
  endpoint;
5916
5921
  timeoutMs;
@@ -5962,6 +5967,9 @@ var StreamableHttpMcpRpcClient = class {
5962
5967
  if (response.status === 403) {
5963
5968
  throw new McpHttpError(403, "MCP server forbidden");
5964
5969
  }
5970
+ if (response.status === 404 && this.sessionId) {
5971
+ throw new McpSessionExpiredError();
5972
+ }
5965
5973
  if (!response.ok) {
5966
5974
  throw new Error(`MCP HTTP request failed with status ${response.status}`);
5967
5975
  }
@@ -6036,20 +6044,32 @@ var StreamableHttpMcpRpcClient = class {
6036
6044
  throw new Error(`MCP response missing JSON-RPC payload for id ${id}`);
6037
6045
  }
6038
6046
  async request(method, params) {
6039
- await this.ensureInitialized();
6040
- const id = this.idCounter++;
6041
- const payload = {
6042
- jsonrpc: "2.0",
6043
- id,
6044
- method,
6045
- params: params ?? {}
6046
- };
6047
- const payloads = await this.postMessage(payload);
6048
- const result = this.extractResult(payloads, id);
6049
- if (result.error) {
6050
- throw new Error(result.error.message ?? `MCP error on ${method}`);
6047
+ for (let attempt = 0; attempt < 2; attempt++) {
6048
+ try {
6049
+ await this.ensureInitialized();
6050
+ const id = this.idCounter++;
6051
+ const payload = {
6052
+ jsonrpc: "2.0",
6053
+ id,
6054
+ method,
6055
+ params: params ?? {}
6056
+ };
6057
+ const payloads = await this.postMessage(payload);
6058
+ const result = this.extractResult(payloads, id);
6059
+ if (result.error) {
6060
+ throw new Error(result.error.message ?? `MCP error on ${method}`);
6061
+ }
6062
+ return result.result;
6063
+ } catch (error) {
6064
+ if (error instanceof McpSessionExpiredError && attempt === 0) {
6065
+ this.sessionId = void 0;
6066
+ this.initialized = false;
6067
+ continue;
6068
+ }
6069
+ throw error;
6070
+ }
6051
6071
  }
6052
- return result.result;
6072
+ throw new Error(`MCP request to ${method} failed after session retry`);
6053
6073
  }
6054
6074
  async ensureInitialized() {
6055
6075
  if (this.initialized) {
@@ -6105,6 +6125,8 @@ var StreamableHttpMcpRpcClient = class {
6105
6125
  }
6106
6126
  }
6107
6127
  };
6128
+ var TENANT_CLIENT_TTL_MS = 15 * 60 * 1e3;
6129
+ var tenantClientKey = (serverName, tenantId) => `${serverName}\0${tenantId}`;
6108
6130
  var LocalMcpBridge = class {
6109
6131
  remoteServers;
6110
6132
  rpcClients = /* @__PURE__ */ new Map();
@@ -6112,6 +6134,18 @@ var LocalMcpBridge = class {
6112
6134
  unavailableServers = /* @__PURE__ */ new Map();
6113
6135
  authFailedServers = /* @__PURE__ */ new Set();
6114
6136
  envResolver;
6137
+ /**
6138
+ * Per-tenant MCP client cache. For consumer/SaaS deployments where every
6139
+ * call resolves a different bearer token, building a fresh
6140
+ * `StreamableHttpMcpRpcClient` per call would force a fresh `initialize`
6141
+ * round-trip every time. We keep one client per `(serverName, tenantId)`
6142
+ * with TTL-based idle eviction; on token rotation we evict the entry
6143
+ * lazily and rebuild.
6144
+ */
6145
+ tenantClients = /* @__PURE__ */ new Map();
6146
+ /** Test/observability hook: bumped every time a new tenant client is constructed. */
6147
+ tenantClientConstructions = 0;
6148
+ tenantClientTtlMs;
6115
6149
  /**
6116
6150
  * Set a resolver for per-tenant env vars (e.g. MCP auth tokens).
6117
6151
  * Called by the harness after creating the secrets store.
@@ -6119,7 +6153,8 @@ var LocalMcpBridge = class {
6119
6153
  setEnvResolver(resolver) {
6120
6154
  this.envResolver = resolver;
6121
6155
  }
6122
- constructor(config) {
6156
+ constructor(config, options) {
6157
+ this.tenantClientTtlMs = options?.tenantClientTtlMs ?? TENANT_CLIENT_TTL_MS;
6123
6158
  this.remoteServers = (config?.mcp ?? []).filter(
6124
6159
  (entry) => typeof entry.url === "string"
6125
6160
  );
@@ -6290,6 +6325,35 @@ var LocalMcpBridge = class {
6290
6325
  await client.close();
6291
6326
  }
6292
6327
  this.rpcClients.clear();
6328
+ for (const [, entry] of this.tenantClients) {
6329
+ await entry.client.close();
6330
+ }
6331
+ this.tenantClients.clear();
6332
+ }
6333
+ getOrCreateTenantClient(serverName, tenantId, token, server) {
6334
+ const key = tenantClientKey(serverName, tenantId);
6335
+ const now2 = Date.now();
6336
+ const existing = this.tenantClients.get(key);
6337
+ if (existing) {
6338
+ const idle = now2 - existing.lastUsed > this.tenantClientTtlMs;
6339
+ const tokenChanged = existing.token !== token;
6340
+ if (idle || tokenChanged) {
6341
+ void existing.client.close();
6342
+ this.tenantClients.delete(key);
6343
+ } else {
6344
+ existing.lastUsed = now2;
6345
+ return existing.client;
6346
+ }
6347
+ }
6348
+ const client = new StreamableHttpMcpRpcClient(
6349
+ server.url,
6350
+ server.timeoutMs ?? 1e4,
6351
+ token,
6352
+ server.headers
6353
+ );
6354
+ this.tenantClients.set(key, { client, token, lastUsed: now2 });
6355
+ this.tenantClientConstructions += 1;
6356
+ return client;
6293
6357
  }
6294
6358
  listServers() {
6295
6359
  return [...this.remoteServers];
@@ -6411,15 +6475,15 @@ var LocalMcpBridge = class {
6411
6475
  try {
6412
6476
  const tokenEnv = server?.auth?.tokenEnv;
6413
6477
  let callClient = client;
6414
- if (tokenEnv && this.envResolver && context?.tenantId) {
6478
+ if (tokenEnv && this.envResolver && context?.tenantId && server) {
6415
6479
  const tenantToken = await this.envResolver(context.tenantId, tokenEnv);
6416
6480
  const defaultToken = process.env[tokenEnv];
6417
6481
  if (tenantToken && tenantToken !== defaultToken) {
6418
- callClient = new StreamableHttpMcpRpcClient(
6419
- server.url,
6420
- server.timeoutMs ?? 1e4,
6482
+ callClient = this.getOrCreateTenantClient(
6483
+ serverName,
6484
+ context.tenantId,
6421
6485
  tenantToken,
6422
- server.headers
6486
+ server
6423
6487
  );
6424
6488
  }
6425
6489
  }
@@ -8640,6 +8704,7 @@ var AgentHarness = class _AgentHarness {
8640
8704
  reminderStore;
8641
8705
  secretsStore;
8642
8706
  loadedConfig;
8707
+ injectedConfig;
8643
8708
  loadedSkills = [];
8644
8709
  skillFingerprint = "";
8645
8710
  lastSkillRefreshAt = 0;
@@ -8654,6 +8719,8 @@ var AgentHarness = class _AgentHarness {
8654
8719
  _browserMod;
8655
8720
  parsedAgent;
8656
8721
  agentFileFingerprint = "";
8722
+ injectedAgentDefinition;
8723
+ injectedStorageEngine = false;
8657
8724
  mcpBridge;
8658
8725
  subagentManager;
8659
8726
  archivedToolResultsByConversation = /* @__PURE__ */ new Map();
@@ -8821,6 +8888,17 @@ var AgentHarness = class _AgentHarness {
8821
8888
  this.modelProviderInjected = !!options.modelProvider;
8822
8889
  this.modelProvider = options.modelProvider ?? createModelProvider("anthropic");
8823
8890
  this.uploadStore = options.uploadStore;
8891
+ this.injectedConfig = options.config;
8892
+ if (options.agentDefinition !== void 0 && options.storageEngine === void 0) {
8893
+ throw new Error(
8894
+ "HarnessOptions.agentDefinition requires HarnessOptions.storageEngine \u2014 construct a StorageEngine with the desired agentId and pass both."
8895
+ );
8896
+ }
8897
+ this.injectedAgentDefinition = options.agentDefinition;
8898
+ if (options.storageEngine) {
8899
+ this.storageEngine = options.storageEngine;
8900
+ this.injectedStorageEngine = true;
8901
+ }
8824
8902
  if (options.toolDefinitions?.length) {
8825
8903
  this.dispatcher.registerMany(options.toolDefinitions);
8826
8904
  }
@@ -9237,6 +9315,9 @@ var AgentHarness = class _AgentHarness {
9237
9315
  if (this.environment !== "development") {
9238
9316
  return false;
9239
9317
  }
9318
+ if (this.injectedAgentDefinition !== void 0) {
9319
+ return false;
9320
+ }
9240
9321
  try {
9241
9322
  const agentFilePath = resolve11(this.workingDir, "AGENT.md");
9242
9323
  const rawContent = await readFile8(agentFilePath, "utf8");
@@ -9304,15 +9385,23 @@ var AgentHarness = class _AgentHarness {
9304
9385
  }
9305
9386
  }
9306
9387
  async initialize() {
9307
- const agentFilePath = resolve11(this.workingDir, "AGENT.md");
9308
- const agentRawContent = await readFile8(agentFilePath, "utf8");
9309
- this.parsedAgent = parseAgentMarkdown(agentRawContent);
9310
- this.agentFileFingerprint = agentRawContent;
9311
- const identity = await ensureAgentIdentity(this.workingDir);
9312
- if (!this.parsedAgent.frontmatter.id) {
9313
- this.parsedAgent.frontmatter.id = identity.id;
9388
+ if (this.injectedAgentDefinition !== void 0) {
9389
+ this.parsedAgent = typeof this.injectedAgentDefinition === "string" ? parseAgentMarkdown(this.injectedAgentDefinition) : this.injectedAgentDefinition;
9390
+ this.agentFileFingerprint = "";
9391
+ if (this.storageEngine) {
9392
+ this.parsedAgent.frontmatter.id = this.storageEngine.agentId;
9393
+ }
9394
+ } else {
9395
+ const agentFilePath = resolve11(this.workingDir, "AGENT.md");
9396
+ const agentRawContent = await readFile8(agentFilePath, "utf8");
9397
+ this.parsedAgent = parseAgentMarkdown(agentRawContent);
9398
+ this.agentFileFingerprint = agentRawContent;
9399
+ const identity = await ensureAgentIdentity(this.workingDir);
9400
+ if (!this.parsedAgent.frontmatter.id) {
9401
+ this.parsedAgent.frontmatter.id = identity.id;
9402
+ }
9314
9403
  }
9315
- const config = await loadPonchoConfig(this.workingDir);
9404
+ const config = this.injectedConfig ?? await loadPonchoConfig(this.workingDir);
9316
9405
  this.loadedConfig = config;
9317
9406
  this.registerConfiguredBuiltInTools(config);
9318
9407
  const provider = this.parsedAgent.frontmatter.model?.provider ?? "anthropic";
@@ -9328,15 +9417,21 @@ var AgentHarness = class _AgentHarness {
9328
9417
  this.skillFingerprint = this.buildSkillFingerprint(skillMetadata);
9329
9418
  this.registerSkillTools();
9330
9419
  const agentId = this.parsedAgent.frontmatter.id ?? this.parsedAgent.frontmatter.name;
9331
- const storageProvider = config?.storage?.provider ?? "sqlite";
9332
- const engine = createStorageEngine({
9333
- provider: storageProvider,
9334
- workingDir: this.workingDir,
9335
- agentId,
9336
- urlEnv: config?.storage?.urlEnv
9337
- });
9338
- await engine.initialize();
9339
- this.storageEngine = engine;
9420
+ let engine;
9421
+ if (this.injectedStorageEngine && this.storageEngine) {
9422
+ engine = this.storageEngine;
9423
+ await engine.initialize();
9424
+ } else {
9425
+ const storageProvider = config?.storage?.provider ?? "sqlite";
9426
+ engine = createStorageEngine({
9427
+ provider: storageProvider,
9428
+ workingDir: this.workingDir,
9429
+ agentId,
9430
+ urlEnv: config?.storage?.urlEnv
9431
+ });
9432
+ await engine.initialize();
9433
+ this.storageEngine = engine;
9434
+ }
9340
9435
  const maxFileSize = config?.storage?.limits?.maxFileSize ?? 100 * 1024 * 1024;
9341
9436
  const maxTotalStorage = config?.storage?.limits?.maxTotalStorage ?? 1024 * 1024 * 1024;
9342
9437
  const bashWorkingDir = this.environment === "production" ? null : this.workingDir;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poncho-ai/harness",
3
- "version": "0.40.1",
3
+ "version": "0.41.0",
4
4
  "description": "Agent execution runtime - conversation loop, tool dispatch, streaming",
5
5
  "repository": {
6
6
  "type": "git",
package/src/harness.ts CHANGED
@@ -102,6 +102,27 @@ export interface HarnessOptions {
102
102
  toolDefinitions?: ToolDefinition[];
103
103
  modelProvider?: ModelProviderFactory;
104
104
  uploadStore?: UploadStore;
105
+ /**
106
+ * Inject the agent definition directly instead of reading AGENT.md from
107
+ * `workingDir`. Pass raw markdown (string) or a pre-parsed `ParsedAgent`.
108
+ * When provided, `storageEngine` is also required — the engine's
109
+ * `agentId` becomes the source of truth for partitioning, and the
110
+ * filesystem identity dance (`ensureAgentIdentity`) is skipped.
111
+ */
112
+ agentDefinition?: string | ParsedAgent;
113
+ /**
114
+ * Pre-constructed storage engine. When provided, the harness will not
115
+ * create one internally. The engine's `agentId` is used wherever the
116
+ * harness today reads `parsedAgent.frontmatter.id`.
117
+ */
118
+ storageEngine?: StorageEngine;
119
+ /**
120
+ * Inject a `PonchoConfig` object directly instead of importing
121
+ * `poncho.config.js` from `workingDir`. When provided, the disk-based
122
+ * loader is skipped. Downstream resolvers (`resolveMemoryConfig`,
123
+ * `resolveStateConfig`, etc.) run as today regardless of source.
124
+ */
125
+ config?: PonchoConfig;
105
126
  }
106
127
 
107
128
  export interface HarnessRunOutput {
@@ -805,6 +826,7 @@ export class AgentHarness {
805
826
  reminderStore?: ReminderStore;
806
827
  secretsStore?: SecretsStore;
807
828
  private loadedConfig?: PonchoConfig;
829
+ private readonly injectedConfig?: PonchoConfig;
808
830
  private loadedSkills: SkillMetadata[] = [];
809
831
  private skillFingerprint = "";
810
832
  private lastSkillRefreshAt = 0;
@@ -823,6 +845,8 @@ export class AgentHarness {
823
845
 
824
846
  private parsedAgent?: ParsedAgent;
825
847
  private agentFileFingerprint = "";
848
+ private injectedAgentDefinition?: string | ParsedAgent;
849
+ private injectedStorageEngine = false;
826
850
  private mcpBridge?: LocalMcpBridge;
827
851
  private subagentManager?: SubagentManager;
828
852
  private readonly archivedToolResultsByConversation = new Map<string, Record<string, ArchivedToolResult>>();
@@ -1011,6 +1035,19 @@ export class AgentHarness {
1011
1035
  this.modelProviderInjected = !!options.modelProvider;
1012
1036
  this.modelProvider = options.modelProvider ?? createModelProvider("anthropic");
1013
1037
  this.uploadStore = options.uploadStore;
1038
+ this.injectedConfig = options.config;
1039
+
1040
+ if (options.agentDefinition !== undefined && options.storageEngine === undefined) {
1041
+ throw new Error(
1042
+ "HarnessOptions.agentDefinition requires HarnessOptions.storageEngine — " +
1043
+ "construct a StorageEngine with the desired agentId and pass both.",
1044
+ );
1045
+ }
1046
+ this.injectedAgentDefinition = options.agentDefinition;
1047
+ if (options.storageEngine) {
1048
+ this.storageEngine = options.storageEngine;
1049
+ this.injectedStorageEngine = true;
1050
+ }
1014
1051
 
1015
1052
  if (options.toolDefinitions?.length) {
1016
1053
  this.dispatcher.registerMany(options.toolDefinitions);
@@ -1508,6 +1545,11 @@ export class AgentHarness {
1508
1545
  if (this.environment !== "development") {
1509
1546
  return false;
1510
1547
  }
1548
+ if (this.injectedAgentDefinition !== undefined) {
1549
+ // Caller owns the agent definition — re-instantiate the harness to
1550
+ // pick up changes rather than re-reading from disk.
1551
+ return false;
1552
+ }
1511
1553
  try {
1512
1554
  const agentFilePath = resolve(this.workingDir, "AGENT.md");
1513
1555
  const rawContent = await readFile(agentFilePath, "utf8");
@@ -1586,15 +1628,28 @@ export class AgentHarness {
1586
1628
  }
1587
1629
 
1588
1630
  async initialize(): Promise<void> {
1589
- const agentFilePath = resolve(this.workingDir, "AGENT.md");
1590
- const agentRawContent = await readFile(agentFilePath, "utf8");
1591
- this.parsedAgent = parseAgentMarkdown(agentRawContent);
1592
- this.agentFileFingerprint = agentRawContent;
1593
- const identity = await ensureAgentIdentity(this.workingDir);
1594
- if (!this.parsedAgent.frontmatter.id) {
1595
- this.parsedAgent.frontmatter.id = identity.id;
1596
- }
1597
- const config = await loadPonchoConfig(this.workingDir);
1631
+ if (this.injectedAgentDefinition !== undefined) {
1632
+ this.parsedAgent = typeof this.injectedAgentDefinition === "string"
1633
+ ? parseAgentMarkdown(this.injectedAgentDefinition)
1634
+ : this.injectedAgentDefinition;
1635
+ this.agentFileFingerprint = "";
1636
+ // The injected StorageEngine is the source of truth for agentId.
1637
+ // Mirror it onto frontmatter.id so existing downstream readers
1638
+ // (`frontmatter.id ?? frontmatter.name`) keep resolving correctly.
1639
+ if (this.storageEngine) {
1640
+ this.parsedAgent.frontmatter.id = this.storageEngine.agentId;
1641
+ }
1642
+ } else {
1643
+ const agentFilePath = resolve(this.workingDir, "AGENT.md");
1644
+ const agentRawContent = await readFile(agentFilePath, "utf8");
1645
+ this.parsedAgent = parseAgentMarkdown(agentRawContent);
1646
+ this.agentFileFingerprint = agentRawContent;
1647
+ const identity = await ensureAgentIdentity(this.workingDir);
1648
+ if (!this.parsedAgent.frontmatter.id) {
1649
+ this.parsedAgent.frontmatter.id = identity.id;
1650
+ }
1651
+ }
1652
+ const config = this.injectedConfig ?? await loadPonchoConfig(this.workingDir);
1598
1653
  this.loadedConfig = config;
1599
1654
  this.registerConfiguredBuiltInTools(config);
1600
1655
  const provider = this.parsedAgent.frontmatter.model?.provider ?? "anthropic";
@@ -1612,15 +1667,23 @@ export class AgentHarness {
1612
1667
  const agentId = this.parsedAgent.frontmatter.id ?? this.parsedAgent.frontmatter.name;
1613
1668
 
1614
1669
  // --- Unified Storage Engine ---
1615
- const storageProvider = (config?.storage?.provider ?? "sqlite") as StorageProvider;
1616
- const engine = createStorageEngine({
1617
- provider: storageProvider,
1618
- workingDir: this.workingDir,
1619
- agentId,
1620
- urlEnv: config?.storage?.urlEnv,
1621
- });
1622
- await engine.initialize();
1623
- this.storageEngine = engine;
1670
+ let engine: StorageEngine;
1671
+ if (this.injectedStorageEngine && this.storageEngine) {
1672
+ // Caller-constructed engine; assume already initialized or will be
1673
+ // initialized by them (initialize() is idempotent in current impls).
1674
+ engine = this.storageEngine;
1675
+ await engine.initialize();
1676
+ } else {
1677
+ const storageProvider = (config?.storage?.provider ?? "sqlite") as StorageProvider;
1678
+ engine = createStorageEngine({
1679
+ provider: storageProvider,
1680
+ workingDir: this.workingDir,
1681
+ agentId,
1682
+ urlEnv: config?.storage?.urlEnv,
1683
+ });
1684
+ await engine.initialize();
1685
+ this.storageEngine = engine;
1686
+ }
1624
1687
 
1625
1688
  // --- Bash Environment Manager ---
1626
1689
  const maxFileSize = config?.storage?.limits?.maxFileSize ?? 100 * 1024 * 1024; // 100MB