@poncho-ai/harness 0.34.1 → 0.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +11 -11
- package/.turbo/turbo-lint.log +6 -0
- package/.turbo/turbo-test.log +11931 -0
- package/CHANGELOG.md +19 -0
- package/dist/index.d.ts +77 -9
- package/dist/index.js +610 -105
- package/package.json +3 -2
- package/src/config.ts +6 -0
- package/src/harness.ts +61 -5
- package/src/index.ts +2 -0
- package/src/mcp.ts +140 -9
- package/src/memory.ts +33 -13
- package/src/reminder-store.ts +6 -0
- package/src/reminder-tools.ts +15 -2
- package/src/secrets-store.ts +252 -0
- package/src/state.ts +41 -19
- package/src/subagent-manager.ts +1 -0
- package/src/subagent-tools.ts +1 -0
- package/src/telemetry.ts +5 -1
- package/src/tenant-token.ts +42 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@poncho-ai/harness",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.35.0",
|
|
4
4
|
"description": "Agent execution runtime - conversation loop, tool dispatch, streaming",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -29,11 +29,12 @@
|
|
|
29
29
|
"ai": "^6.0.86",
|
|
30
30
|
"cheerio": "^1.2.0",
|
|
31
31
|
"jiti": "^2.6.1",
|
|
32
|
+
"jose": "^6.2.2",
|
|
32
33
|
"mustache": "^4.2.0",
|
|
33
34
|
"redis": "^5.10.0",
|
|
34
35
|
"yaml": "^2.4.0",
|
|
35
36
|
"zod": "^3.22.0",
|
|
36
|
-
"@poncho-ai/sdk": "1.
|
|
37
|
+
"@poncho-ai/sdk": "1.8.0"
|
|
37
38
|
},
|
|
38
39
|
"devDependencies": {
|
|
39
40
|
"@types/mustache": "^4.2.6",
|
package/src/config.ts
CHANGED
|
@@ -134,6 +134,12 @@ export interface PonchoConfig extends McpConfig {
|
|
|
134
134
|
/** Cron expression controlling how often the reminder poll runs (local and serverless). Default: every 10 minutes. */
|
|
135
135
|
pollSchedule?: string;
|
|
136
136
|
};
|
|
137
|
+
/**
|
|
138
|
+
* Declare env var names that tenants can self-manage via the web UI or API.
|
|
139
|
+
* Key = env var name, value = human-readable label shown in the settings panel.
|
|
140
|
+
* Example: { LINEAR_API_KEY: "Linear API Key", STRIPE_KEY: "Stripe Secret Key" }
|
|
141
|
+
*/
|
|
142
|
+
tenantSecrets?: Record<string, string>;
|
|
137
143
|
/** Set to `false` to disable the built-in web UI (headless / API-only mode). */
|
|
138
144
|
webUi?: false;
|
|
139
145
|
/** Enable browser automation tools. Set `true` for defaults, or provide config. */
|
package/src/harness.ts
CHANGED
|
@@ -21,10 +21,12 @@ import { createDefaultTools, createDeleteDirectoryTool, createDeleteTool, create
|
|
|
21
21
|
import {
|
|
22
22
|
createMemoryStore,
|
|
23
23
|
createMemoryTools,
|
|
24
|
+
type MemoryConfig,
|
|
24
25
|
type MemoryStore,
|
|
25
26
|
} from "./memory.js";
|
|
26
27
|
import { createTodoStore, createTodoTools, type TodoItem, type TodoStore } from "./todo-tools.js";
|
|
27
28
|
import { createReminderStore, type ReminderStore } from "./reminder-store.js";
|
|
29
|
+
import { createSecretsStore, resolveEnv, type SecretsStore } from "./secrets-store.js";
|
|
28
30
|
import { createReminderTools } from "./reminder-tools.js";
|
|
29
31
|
import { LocalMcpBridge } from "./mcp.js";
|
|
30
32
|
import { createModelProvider, getModelContextWindow, type ModelProviderFactory, type ProviderConfig } from "./model-factory.js";
|
|
@@ -639,8 +641,11 @@ export class AgentHarness {
|
|
|
639
641
|
readonly uploadStore?: UploadStore;
|
|
640
642
|
private skillContextWindow = "";
|
|
641
643
|
private memoryStore?: MemoryStore;
|
|
644
|
+
private readonly tenantMemoryStores = new Map<string, MemoryStore>();
|
|
645
|
+
private memoryConfig?: MemoryConfig;
|
|
642
646
|
private todoStore?: TodoStore;
|
|
643
647
|
reminderStore?: ReminderStore;
|
|
648
|
+
secretsStore?: SecretsStore;
|
|
644
649
|
private loadedConfig?: PonchoConfig;
|
|
645
650
|
private loadedSkills: SkillMetadata[] = [];
|
|
646
651
|
private skillFingerprint = "";
|
|
@@ -975,6 +980,31 @@ export class AgentHarness {
|
|
|
975
980
|
return this.todoStore.get(conversationId);
|
|
976
981
|
}
|
|
977
982
|
|
|
983
|
+
/**
|
|
984
|
+
* Get a memory store, optionally scoped to a tenant.
|
|
985
|
+
* Returns the default (agent-wide) store when tenantId is null/undefined.
|
|
986
|
+
*/
|
|
987
|
+
private getMemoryStore(tenantId?: string): MemoryStore | undefined {
|
|
988
|
+
if (!this.memoryConfig?.enabled) return undefined;
|
|
989
|
+
if (!tenantId) return this.memoryStore;
|
|
990
|
+
|
|
991
|
+
let store = this.tenantMemoryStores.get(tenantId);
|
|
992
|
+
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
|
+
});
|
|
998
|
+
this.tenantMemoryStores.set(tenantId, store);
|
|
999
|
+
// Evict oldest entries if cache grows too large
|
|
1000
|
+
if (this.tenantMemoryStores.size > 100) {
|
|
1001
|
+
const oldest = this.tenantMemoryStores.keys().next().value;
|
|
1002
|
+
if (oldest) this.tenantMemoryStores.delete(oldest);
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
return store;
|
|
1006
|
+
}
|
|
1007
|
+
|
|
978
1008
|
private listActiveSkills(): string[] {
|
|
979
1009
|
return [...this.activeSkillNames].sort();
|
|
980
1010
|
}
|
|
@@ -1164,6 +1194,7 @@ export class AgentHarness {
|
|
|
1164
1194
|
if (!this.mcpBridge) {
|
|
1165
1195
|
return;
|
|
1166
1196
|
}
|
|
1197
|
+
|
|
1167
1198
|
const requestedPatterns = this.getRequestedMcpPatterns();
|
|
1168
1199
|
this.dispatcher.unregisterMany(this.registeredMcpToolNames);
|
|
1169
1200
|
this.registeredMcpToolNames.clear();
|
|
@@ -1354,6 +1385,7 @@ export class AgentHarness {
|
|
|
1354
1385
|
this.registerSkillTools(skillMetadata);
|
|
1355
1386
|
const agentId = this.parsedAgent.frontmatter.id ?? this.parsedAgent.frontmatter.name;
|
|
1356
1387
|
|
|
1388
|
+
this.memoryConfig = memoryConfig ?? undefined;
|
|
1357
1389
|
if (memoryConfig?.enabled) {
|
|
1358
1390
|
this.memoryStore = createMemoryStore(
|
|
1359
1391
|
agentId,
|
|
@@ -1361,9 +1393,10 @@ export class AgentHarness {
|
|
|
1361
1393
|
{ workingDir: this.workingDir },
|
|
1362
1394
|
);
|
|
1363
1395
|
this.dispatcher.registerMany(
|
|
1364
|
-
createMemoryTools(
|
|
1365
|
-
|
|
1366
|
-
|
|
1396
|
+
createMemoryTools(
|
|
1397
|
+
(ctx) => this.getMemoryStore(ctx.tenantId) ?? this.memoryStore!,
|
|
1398
|
+
{ maxRecallConversations: memoryConfig.maxRecallConversations },
|
|
1399
|
+
),
|
|
1367
1400
|
);
|
|
1368
1401
|
}
|
|
1369
1402
|
|
|
@@ -1393,6 +1426,16 @@ export class AgentHarness {
|
|
|
1393
1426
|
});
|
|
1394
1427
|
}
|
|
1395
1428
|
|
|
1429
|
+
// Secrets store for per-tenant env var overrides
|
|
1430
|
+
const authTokenEnv = config?.auth?.tokenEnv ?? "PONCHO_AUTH_TOKEN";
|
|
1431
|
+
const authToken = process.env[authTokenEnv];
|
|
1432
|
+
if (authToken) {
|
|
1433
|
+
this.secretsStore = createSecretsStore(agentId, authToken, stateConfig, { workingDir: this.workingDir });
|
|
1434
|
+
bridge.setEnvResolver(async (tenantId, envName) => {
|
|
1435
|
+
return resolveEnv(this.secretsStore, tenantId, envName);
|
|
1436
|
+
});
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1396
1439
|
await bridge.startLocalServers();
|
|
1397
1440
|
await bridge.discoverTools();
|
|
1398
1441
|
await this.refreshMcpTools("initialize");
|
|
@@ -1609,6 +1652,7 @@ export class AgentHarness {
|
|
|
1609
1652
|
attributes: {
|
|
1610
1653
|
"gen_ai.operation.name": "invoke_agent",
|
|
1611
1654
|
...(input.conversationId ? { "gen_ai.conversation.id": input.conversationId } : {}),
|
|
1655
|
+
...(input.tenantId ? { "tenant.id": input.tenantId } : {}),
|
|
1612
1656
|
},
|
|
1613
1657
|
});
|
|
1614
1658
|
|
|
@@ -1662,8 +1706,9 @@ export class AgentHarness {
|
|
|
1662
1706
|
await this.initialize();
|
|
1663
1707
|
}
|
|
1664
1708
|
// Start memory + todo fetches early so they overlap with refresh I/O
|
|
1665
|
-
const
|
|
1666
|
-
|
|
1709
|
+
const activeMemoryStore = this.getMemoryStore(input.tenantId);
|
|
1710
|
+
const memoryPromise = activeMemoryStore
|
|
1711
|
+
? activeMemoryStore.getMainMemory()
|
|
1667
1712
|
: undefined;
|
|
1668
1713
|
const todosPromise = this.todoStore
|
|
1669
1714
|
? this.todoStore.get(input.conversationId ?? "__default__")
|
|
@@ -1671,6 +1716,16 @@ export class AgentHarness {
|
|
|
1671
1716
|
await this.refreshAgentIfChanged();
|
|
1672
1717
|
await this.refreshSkillsIfChanged();
|
|
1673
1718
|
|
|
1719
|
+
// Deferred MCP discovery: servers that couldn't discover at startup because the
|
|
1720
|
+
// env var was missing (tenant secrets provide the token instead).
|
|
1721
|
+
if (input.tenantId && this.mcpBridge?.hasDeferredServers()) {
|
|
1722
|
+
const newTools = await this.mcpBridge.discoverAndLoadDeferred(input.tenantId);
|
|
1723
|
+
for (const tool of newTools) {
|
|
1724
|
+
this.dispatcher.register(tool);
|
|
1725
|
+
this.registeredMcpToolNames.add(tool.name);
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1674
1729
|
let agent = this.parsedAgent as ParsedAgent;
|
|
1675
1730
|
const runId = `run_${randomUUID()}`;
|
|
1676
1731
|
const start = now();
|
|
@@ -2593,6 +2648,7 @@ ${boundedMainMemory.trim()}`
|
|
|
2593
2648
|
parameters: input.parameters ?? {},
|
|
2594
2649
|
abortSignal: input.abortSignal,
|
|
2595
2650
|
conversationId: input.conversationId,
|
|
2651
|
+
tenantId: input.tenantId,
|
|
2596
2652
|
};
|
|
2597
2653
|
|
|
2598
2654
|
const toolResultsForModel: Array<{
|
package/src/index.ts
CHANGED
|
@@ -17,6 +17,8 @@ export * from "./reminder-tools.js";
|
|
|
17
17
|
export * from "./state.js";
|
|
18
18
|
export * from "./upload-store.js";
|
|
19
19
|
export * from "./telemetry.js";
|
|
20
|
+
export * from "./secrets-store.js";
|
|
21
|
+
export * from "./tenant-token.js";
|
|
20
22
|
export * from "./tool-dispatcher.js";
|
|
21
23
|
export * from "./subagent-manager.js";
|
|
22
24
|
export * from "./subagent-tools.js";
|
package/src/mcp.ts
CHANGED
|
@@ -273,6 +273,15 @@ export class LocalMcpBridge {
|
|
|
273
273
|
private readonly toolCatalog = new Map<string, McpToolDescriptor[]>();
|
|
274
274
|
private readonly unavailableServers = new Map<string, string>();
|
|
275
275
|
private readonly authFailedServers = new Set<string>();
|
|
276
|
+
private envResolver?: (tenantId: string | undefined, envName: string) => Promise<string | undefined>;
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Set a resolver for per-tenant env vars (e.g. MCP auth tokens).
|
|
280
|
+
* Called by the harness after creating the secrets store.
|
|
281
|
+
*/
|
|
282
|
+
setEnvResolver(resolver: (tenantId: string | undefined, envName: string) => Promise<string | undefined>): void {
|
|
283
|
+
this.envResolver = resolver;
|
|
284
|
+
}
|
|
276
285
|
|
|
277
286
|
constructor(config: McpConfig | undefined) {
|
|
278
287
|
this.remoteServers = (config?.mcp ?? []).filter((entry): entry is RemoteMcpServerConfig =>
|
|
@@ -316,8 +325,12 @@ export class LocalMcpBridge {
|
|
|
316
325
|
console.info(`[poncho][mcp] ${line}`);
|
|
317
326
|
}
|
|
318
327
|
|
|
328
|
+
/** Set of servers where discovery was deferred (no default token, has env resolver). */
|
|
329
|
+
private readonly deferredDiscoveryServers = new Set<string>();
|
|
330
|
+
|
|
319
331
|
async discoverTools(): Promise<void> {
|
|
320
332
|
this.toolCatalog.clear();
|
|
333
|
+
this.deferredDiscoveryServers.clear();
|
|
321
334
|
for (const remoteServer of this.remoteServers) {
|
|
322
335
|
const name = this.getServerName(remoteServer);
|
|
323
336
|
if (this.unavailableServers.has(name)) {
|
|
@@ -338,6 +351,13 @@ export class LocalMcpBridge {
|
|
|
338
351
|
} catch (error) {
|
|
339
352
|
const message = error instanceof Error ? error.message : String(error);
|
|
340
353
|
if (error instanceof McpHttpError && error.status === 401) {
|
|
354
|
+
if (this.envResolver && remoteServer.auth?.tokenEnv) {
|
|
355
|
+
// Discovery failed without token but tenant secrets may provide one —
|
|
356
|
+
// defer discovery to first loadTools() call with a tenant context
|
|
357
|
+
this.deferredDiscoveryServers.add(name);
|
|
358
|
+
this.log("info", "catalog.deferred", { server: name, reason: "auth_deferred_to_tenant" });
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
341
361
|
this.authFailedServers.add(name);
|
|
342
362
|
this.log("warn", "auth.failed", {
|
|
343
363
|
server: name,
|
|
@@ -351,6 +371,64 @@ export class LocalMcpBridge {
|
|
|
351
371
|
}
|
|
352
372
|
}
|
|
353
373
|
|
|
374
|
+
/**
|
|
375
|
+
* Run deferred discovery and return ToolDefinitions for all newly discovered tools.
|
|
376
|
+
* Call this during run() so the tools are available to the model immediately.
|
|
377
|
+
*/
|
|
378
|
+
async discoverAndLoadDeferred(tenantId: string): Promise<ToolDefinition[]> {
|
|
379
|
+
if (this.deferredDiscoveryServers.size === 0) return [];
|
|
380
|
+
for (const server of this.remoteServers) {
|
|
381
|
+
await this.tryDeferredDiscovery(server, tenantId);
|
|
382
|
+
}
|
|
383
|
+
// Build tool definitions only for servers that were deferred and now have tools
|
|
384
|
+
const tools: ToolDefinition[] = [];
|
|
385
|
+
for (const server of this.remoteServers) {
|
|
386
|
+
const name = this.getServerName(server);
|
|
387
|
+
// Only include servers that WERE deferred (whether discovery succeeded or not)
|
|
388
|
+
if (!server.auth?.tokenEnv) continue;
|
|
389
|
+
const discovered = this.toolCatalog.get(name);
|
|
390
|
+
if (!discovered || discovered.length === 0) continue;
|
|
391
|
+
const client = this.rpcClients.get(name);
|
|
392
|
+
if (!client) continue;
|
|
393
|
+
tools.push(...this.toToolDefinitions(name, discovered, client, server));
|
|
394
|
+
}
|
|
395
|
+
return tools;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
private async tryDeferredDiscovery(server: RemoteMcpServerConfig, tenantId: string): Promise<void> {
|
|
399
|
+
const name = this.getServerName(server);
|
|
400
|
+
if (!this.deferredDiscoveryServers.has(name)) return;
|
|
401
|
+
if (this.toolCatalog.has(name)) return; // already discovered
|
|
402
|
+
|
|
403
|
+
const tokenEnv = server.auth?.tokenEnv;
|
|
404
|
+
if (!tokenEnv || !this.envResolver) return;
|
|
405
|
+
|
|
406
|
+
const token = await this.envResolver(tenantId, tokenEnv);
|
|
407
|
+
if (!token) return;
|
|
408
|
+
|
|
409
|
+
try {
|
|
410
|
+
const probeClient = new StreamableHttpMcpRpcClient(
|
|
411
|
+
server.url,
|
|
412
|
+
server.timeoutMs ?? 10_000,
|
|
413
|
+
token,
|
|
414
|
+
server.headers,
|
|
415
|
+
);
|
|
416
|
+
const discovered = await probeClient.listTools();
|
|
417
|
+
this.toolCatalog.set(name, discovered);
|
|
418
|
+
this.deferredDiscoveryServers.delete(name);
|
|
419
|
+
this.log("info", "catalog.loaded", {
|
|
420
|
+
server: name,
|
|
421
|
+
discoveredCount: discovered.length,
|
|
422
|
+
via: "deferred_tenant_discovery",
|
|
423
|
+
});
|
|
424
|
+
} catch (error) {
|
|
425
|
+
this.log("warn", "catalog.deferred_failed", {
|
|
426
|
+
server: name,
|
|
427
|
+
error: error instanceof Error ? error.message : String(error),
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
354
432
|
async startLocalServers(): Promise<void> {
|
|
355
433
|
this.unavailableServers.clear();
|
|
356
434
|
for (const server of this.remoteServers) {
|
|
@@ -359,15 +437,24 @@ export class LocalMcpBridge {
|
|
|
359
437
|
if (tokenEnv) {
|
|
360
438
|
const token = process.env[tokenEnv];
|
|
361
439
|
if (!token || token.trim().length === 0) {
|
|
362
|
-
this.
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
440
|
+
if (!this.envResolver) {
|
|
441
|
+
// No tenant secrets resolver — server is genuinely unavailable
|
|
442
|
+
this.unavailableServers.set(
|
|
443
|
+
name,
|
|
444
|
+
`Missing bearer token value from env var ${tokenEnv}`,
|
|
445
|
+
);
|
|
446
|
+
this.log("warn", "auth.token_missing", {
|
|
447
|
+
server: name,
|
|
448
|
+
tokenEnv,
|
|
449
|
+
});
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
// Tenant secrets resolver available — create client without a default token;
|
|
453
|
+
// per-tenant tokens will be resolved at call time in toToolDefinitions
|
|
454
|
+
this.log("info", "auth.token_deferred", {
|
|
367
455
|
server: name,
|
|
368
456
|
tokenEnv,
|
|
369
457
|
});
|
|
370
|
-
continue;
|
|
371
458
|
}
|
|
372
459
|
}
|
|
373
460
|
this.rpcClients.set(
|
|
@@ -441,6 +528,31 @@ export class LocalMcpBridge {
|
|
|
441
528
|
return output.sort();
|
|
442
529
|
}
|
|
443
530
|
|
|
531
|
+
hasDeferredServers(): boolean {
|
|
532
|
+
return this.deferredDiscoveryServers.size > 0;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Return ToolDefinitions for catalog tools not already registered in the dispatcher.
|
|
537
|
+
*/
|
|
538
|
+
getUnregisteredTools(registeredNames: Set<string>): ToolDefinition[] {
|
|
539
|
+
const tools: ToolDefinition[] = [];
|
|
540
|
+
for (const server of this.remoteServers) {
|
|
541
|
+
const name = this.getServerName(server);
|
|
542
|
+
const discovered = this.toolCatalog.get(name);
|
|
543
|
+
if (!discovered || discovered.length === 0) continue;
|
|
544
|
+
const client = this.rpcClients.get(name);
|
|
545
|
+
if (!client) continue;
|
|
546
|
+
const unregistered = discovered.filter(
|
|
547
|
+
(d) => !registeredNames.has(`${name}/${d.name}`),
|
|
548
|
+
);
|
|
549
|
+
if (unregistered.length > 0) {
|
|
550
|
+
tools.push(...this.toToolDefinitions(name, unregistered, client, server));
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
return tools;
|
|
554
|
+
}
|
|
555
|
+
|
|
444
556
|
async loadTools(
|
|
445
557
|
requestedPatterns: string[],
|
|
446
558
|
): Promise<ToolDefinition[]> {
|
|
@@ -474,7 +586,7 @@ export class LocalMcpBridge {
|
|
|
474
586
|
const selectedDescriptors = discovered.filter((descriptor) =>
|
|
475
587
|
selectedRawNames.has(descriptor.name),
|
|
476
588
|
);
|
|
477
|
-
tools.push(...this.toToolDefinitions(serverName, selectedDescriptors, client));
|
|
589
|
+
tools.push(...this.toToolDefinitions(serverName, selectedDescriptors, client, server));
|
|
478
590
|
}
|
|
479
591
|
this.log("info", "tools.selected", {
|
|
480
592
|
requestedPatternCount: requestedPatterns.length,
|
|
@@ -489,6 +601,7 @@ export class LocalMcpBridge {
|
|
|
489
601
|
serverName: string,
|
|
490
602
|
tools: McpToolDescriptor[],
|
|
491
603
|
client: McpRpcClient,
|
|
604
|
+
server?: RemoteMcpServerConfig,
|
|
492
605
|
): ToolDefinition[] {
|
|
493
606
|
return tools.map((tool) => ({
|
|
494
607
|
name: `${serverName}/${tool.name}`,
|
|
@@ -498,9 +611,27 @@ export class LocalMcpBridge {
|
|
|
498
611
|
type: "object",
|
|
499
612
|
properties: {},
|
|
500
613
|
},
|
|
501
|
-
handler: async (input) => {
|
|
614
|
+
handler: async (input, context) => {
|
|
502
615
|
try {
|
|
503
|
-
|
|
616
|
+
// Per-tenant token resolution: if we have a resolver and the server uses tokenEnv,
|
|
617
|
+
// create a per-request client with the tenant-specific token
|
|
618
|
+
const tokenEnv = server?.auth?.tokenEnv;
|
|
619
|
+
let callClient = client;
|
|
620
|
+
if (tokenEnv && this.envResolver && context?.tenantId) {
|
|
621
|
+
const tenantToken = await this.envResolver(context.tenantId, tokenEnv);
|
|
622
|
+
const defaultToken = process.env[tokenEnv];
|
|
623
|
+
// Only create a per-request client when the tenant has a different token.
|
|
624
|
+
// Using the original client preserves the established MCP session.
|
|
625
|
+
if (tenantToken && tenantToken !== defaultToken) {
|
|
626
|
+
callClient = new StreamableHttpMcpRpcClient(
|
|
627
|
+
server.url,
|
|
628
|
+
server.timeoutMs ?? 10_000,
|
|
629
|
+
tenantToken,
|
|
630
|
+
server.headers,
|
|
631
|
+
);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
return await callClient.callTool(tool.name, input);
|
|
504
635
|
} catch (error) {
|
|
505
636
|
if (error instanceof McpHttpError && error.status === 401) {
|
|
506
637
|
this.authFailedServers.add(serverName);
|
package/src/memory.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
2
2
|
import { dirname, resolve } from "node:path";
|
|
3
|
-
import { defineTool, type ToolDefinition } from "@poncho-ai/sdk";
|
|
3
|
+
import { defineTool, type ToolContext, type ToolDefinition } from "@poncho-ai/sdk";
|
|
4
4
|
import type { StateProviderName } from "./state.js";
|
|
5
5
|
import {
|
|
6
6
|
ensureAgentIdentity,
|
|
@@ -102,14 +102,16 @@ class InMemoryMemoryStore implements MemoryStore {
|
|
|
102
102
|
class FileMainMemoryStore implements MemoryStore {
|
|
103
103
|
private readonly workingDir: string;
|
|
104
104
|
private filePath = "";
|
|
105
|
+
private readonly customRelPath?: string;
|
|
105
106
|
private readonly ttlMs?: number;
|
|
106
107
|
private loaded = false;
|
|
107
108
|
private writing = Promise.resolve();
|
|
108
109
|
private mainMemory: MainMemory = { ...DEFAULT_MAIN_MEMORY };
|
|
109
110
|
|
|
110
|
-
constructor(workingDir: string, ttlSeconds?: number) {
|
|
111
|
+
constructor(workingDir: string, ttlSeconds?: number, customRelPath?: string) {
|
|
111
112
|
this.workingDir = workingDir;
|
|
112
113
|
this.ttlMs = typeof ttlSeconds === "number" ? ttlSeconds * 1000 : undefined;
|
|
114
|
+
this.customRelPath = customRelPath;
|
|
113
115
|
}
|
|
114
116
|
|
|
115
117
|
private async ensureFilePath(): Promise<void> {
|
|
@@ -117,7 +119,10 @@ class FileMainMemoryStore implements MemoryStore {
|
|
|
117
119
|
return;
|
|
118
120
|
}
|
|
119
121
|
const identity = await ensureAgentIdentity(this.workingDir);
|
|
120
|
-
this.filePath = resolve(
|
|
122
|
+
this.filePath = resolve(
|
|
123
|
+
getAgentStoreDirectory(identity),
|
|
124
|
+
this.customRelPath ?? LOCAL_MEMORY_FILE,
|
|
125
|
+
);
|
|
121
126
|
}
|
|
122
127
|
|
|
123
128
|
private isExpired(updatedAt: number): boolean {
|
|
@@ -225,13 +230,22 @@ class KVBackedMemoryStore implements MemoryStore {
|
|
|
225
230
|
export const createMemoryStore = (
|
|
226
231
|
agentId: string,
|
|
227
232
|
config?: MemoryConfig,
|
|
228
|
-
options?: { workingDir?: string },
|
|
233
|
+
options?: { workingDir?: string; tenantId?: string },
|
|
229
234
|
): MemoryStore => {
|
|
230
235
|
const provider = config?.provider ?? "local";
|
|
231
236
|
const ttl = config?.ttl;
|
|
232
237
|
const workingDir = options?.workingDir ?? process.cwd();
|
|
238
|
+
const tenantId = options?.tenantId;
|
|
233
239
|
|
|
234
240
|
if (provider === "local") {
|
|
241
|
+
if (tenantId) {
|
|
242
|
+
// Tenant-scoped memory: store under tenants/{tenantId}/memory.json
|
|
243
|
+
return new FileMainMemoryStore(
|
|
244
|
+
workingDir,
|
|
245
|
+
ttl,
|
|
246
|
+
`tenants/${slugifyStorageComponent(tenantId)}/${LOCAL_MEMORY_FILE}`,
|
|
247
|
+
);
|
|
248
|
+
}
|
|
235
249
|
return new FileMainMemoryStore(workingDir, ttl);
|
|
236
250
|
}
|
|
237
251
|
if (provider === "memory") {
|
|
@@ -240,7 +254,10 @@ export const createMemoryStore = (
|
|
|
240
254
|
|
|
241
255
|
const kv = createRawKVStore(config);
|
|
242
256
|
if (kv) {
|
|
243
|
-
const
|
|
257
|
+
const base = `poncho:${STORAGE_SCHEMA_VERSION}:${slugifyStorageComponent(agentId)}`;
|
|
258
|
+
const storageKey = tenantId
|
|
259
|
+
? `${base}:t:${slugifyStorageComponent(tenantId)}:memory:main`
|
|
260
|
+
: `${base}:memory:main`;
|
|
244
261
|
return new KVBackedMemoryStore(kv, storageKey, ttl);
|
|
245
262
|
}
|
|
246
263
|
return new InMemoryMemoryStore(ttl);
|
|
@@ -281,9 +298,12 @@ const buildRecallSnippet = (content: string, query: string, maxChars = 360): str
|
|
|
281
298
|
};
|
|
282
299
|
|
|
283
300
|
export const createMemoryTools = (
|
|
284
|
-
store: MemoryStore,
|
|
301
|
+
store: MemoryStore | ((context: ToolContext) => MemoryStore),
|
|
285
302
|
options?: { maxRecallConversations?: number },
|
|
286
303
|
): ToolDefinition[] => {
|
|
304
|
+
const resolveStore = typeof store === "function"
|
|
305
|
+
? store
|
|
306
|
+
: () => store;
|
|
287
307
|
const maxRecallConversations = Math.max(1, options?.maxRecallConversations ?? 20);
|
|
288
308
|
return [
|
|
289
309
|
defineTool({
|
|
@@ -294,8 +314,8 @@ export const createMemoryTools = (
|
|
|
294
314
|
properties: {},
|
|
295
315
|
additionalProperties: false,
|
|
296
316
|
},
|
|
297
|
-
handler: async () => {
|
|
298
|
-
const memory = await
|
|
317
|
+
handler: async (_input, context) => {
|
|
318
|
+
const memory = await resolveStore(context).getMainMemory();
|
|
299
319
|
return { memory };
|
|
300
320
|
},
|
|
301
321
|
}),
|
|
@@ -316,12 +336,12 @@ export const createMemoryTools = (
|
|
|
316
336
|
required: ["content"],
|
|
317
337
|
additionalProperties: false,
|
|
318
338
|
},
|
|
319
|
-
handler: async (input) => {
|
|
339
|
+
handler: async (input, context) => {
|
|
320
340
|
const content = typeof input.content === "string" ? input.content.trim() : "";
|
|
321
341
|
if (!content) {
|
|
322
342
|
throw new Error("content is required");
|
|
323
343
|
}
|
|
324
|
-
const memory = await
|
|
344
|
+
const memory = await resolveStore(context).updateMainMemory({ content });
|
|
325
345
|
return { ok: true, memory };
|
|
326
346
|
},
|
|
327
347
|
}),
|
|
@@ -349,13 +369,13 @@ export const createMemoryTools = (
|
|
|
349
369
|
required: ["old_str", "new_str"],
|
|
350
370
|
additionalProperties: false,
|
|
351
371
|
},
|
|
352
|
-
handler: async (input) => {
|
|
372
|
+
handler: async (input, context) => {
|
|
353
373
|
const oldStr = typeof input.old_str === "string" ? input.old_str : "";
|
|
354
374
|
const newStr = typeof input.new_str === "string" ? input.new_str : "";
|
|
355
375
|
if (!oldStr) {
|
|
356
376
|
throw new Error("old_str must not be empty.");
|
|
357
377
|
}
|
|
358
|
-
const current = await
|
|
378
|
+
const current = await resolveStore(context).getMainMemory();
|
|
359
379
|
const content = current.content;
|
|
360
380
|
const first = content.indexOf(oldStr);
|
|
361
381
|
if (first === -1) {
|
|
@@ -370,7 +390,7 @@ export const createMemoryTools = (
|
|
|
370
390
|
);
|
|
371
391
|
}
|
|
372
392
|
const newContent = content.slice(0, first) + newStr + content.slice(first + oldStr.length);
|
|
373
|
-
const memory = await
|
|
393
|
+
const memory = await resolveStore(context).updateMainMemory({ content: newContent });
|
|
374
394
|
return { ok: true, memory };
|
|
375
395
|
},
|
|
376
396
|
}),
|
package/src/reminder-store.ts
CHANGED
|
@@ -24,6 +24,7 @@ export interface Reminder {
|
|
|
24
24
|
createdAt: number;
|
|
25
25
|
conversationId: string;
|
|
26
26
|
ownerId?: string;
|
|
27
|
+
tenantId?: string | null;
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
export interface ReminderStore {
|
|
@@ -34,6 +35,7 @@ export interface ReminderStore {
|
|
|
34
35
|
timezone?: string;
|
|
35
36
|
conversationId: string;
|
|
36
37
|
ownerId?: string;
|
|
38
|
+
tenantId?: string | null;
|
|
37
39
|
}): Promise<Reminder>;
|
|
38
40
|
cancel(id: string): Promise<Reminder>;
|
|
39
41
|
delete(id: string): Promise<void>;
|
|
@@ -97,6 +99,7 @@ class InMemoryReminderStore implements ReminderStore {
|
|
|
97
99
|
timezone?: string;
|
|
98
100
|
conversationId: string;
|
|
99
101
|
ownerId?: string;
|
|
102
|
+
tenantId?: string | null;
|
|
100
103
|
}): Promise<Reminder> {
|
|
101
104
|
const reminder: Reminder = {
|
|
102
105
|
id: generateId(),
|
|
@@ -107,6 +110,7 @@ class InMemoryReminderStore implements ReminderStore {
|
|
|
107
110
|
createdAt: Date.now(),
|
|
108
111
|
conversationId: input.conversationId,
|
|
109
112
|
ownerId: input.ownerId,
|
|
113
|
+
tenantId: input.tenantId,
|
|
110
114
|
};
|
|
111
115
|
this.reminders = pruneStale(this.reminders);
|
|
112
116
|
this.reminders.push(reminder);
|
|
@@ -175,6 +179,7 @@ class FileReminderStore implements ReminderStore {
|
|
|
175
179
|
timezone?: string;
|
|
176
180
|
conversationId: string;
|
|
177
181
|
ownerId?: string;
|
|
182
|
+
tenantId?: string | null;
|
|
178
183
|
}): Promise<Reminder> {
|
|
179
184
|
const reminder: Reminder = {
|
|
180
185
|
id: generateId(),
|
|
@@ -185,6 +190,7 @@ class FileReminderStore implements ReminderStore {
|
|
|
185
190
|
createdAt: Date.now(),
|
|
186
191
|
conversationId: input.conversationId,
|
|
187
192
|
ownerId: input.ownerId,
|
|
193
|
+
tenantId: input.tenantId,
|
|
188
194
|
};
|
|
189
195
|
let reminders = await this.readAll();
|
|
190
196
|
reminders = pruneStale(reminders);
|
package/src/reminder-tools.ts
CHANGED
|
@@ -84,6 +84,7 @@ export const createReminderTools = (store: ReminderStore): ToolDefinition[] => [
|
|
|
84
84
|
scheduledAt,
|
|
85
85
|
timezone,
|
|
86
86
|
conversationId,
|
|
87
|
+
tenantId: context.tenantId,
|
|
87
88
|
});
|
|
88
89
|
|
|
89
90
|
return {
|
|
@@ -116,8 +117,12 @@ export const createReminderTools = (store: ReminderStore): ToolDefinition[] => [
|
|
|
116
117
|
},
|
|
117
118
|
additionalProperties: false,
|
|
118
119
|
},
|
|
119
|
-
handler: async (input) => {
|
|
120
|
+
handler: async (input, context) => {
|
|
120
121
|
let reminders = await store.list();
|
|
122
|
+
// Tenant-scoped: only show reminders belonging to the current tenant
|
|
123
|
+
if (context.tenantId) {
|
|
124
|
+
reminders = reminders.filter((r) => r.tenantId === context.tenantId);
|
|
125
|
+
}
|
|
121
126
|
const status = typeof input.status === "string" ? input.status : undefined;
|
|
122
127
|
if (status && VALID_STATUSES.includes(status as ReminderStatus)) {
|
|
123
128
|
reminders = reminders.filter((r) => r.status === status);
|
|
@@ -150,9 +155,17 @@ export const createReminderTools = (store: ReminderStore): ToolDefinition[] => [
|
|
|
150
155
|
required: ["id"],
|
|
151
156
|
additionalProperties: false,
|
|
152
157
|
},
|
|
153
|
-
handler: async (input) => {
|
|
158
|
+
handler: async (input, context) => {
|
|
154
159
|
const id = typeof input.id === "string" ? input.id.trim() : "";
|
|
155
160
|
if (!id) throw new Error("id is required");
|
|
161
|
+
// Validate tenant ownership before cancelling
|
|
162
|
+
if (context.tenantId) {
|
|
163
|
+
const all = await store.list();
|
|
164
|
+
const target = all.find((r) => r.id === id);
|
|
165
|
+
if (target && target.tenantId !== context.tenantId) {
|
|
166
|
+
throw new Error("Reminder not found");
|
|
167
|
+
}
|
|
168
|
+
}
|
|
156
169
|
const cancelled = await store.cancel(id);
|
|
157
170
|
return {
|
|
158
171
|
ok: true,
|