@poncho-ai/harness 0.14.0 → 0.14.2

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/src/harness.ts CHANGED
@@ -22,7 +22,7 @@ import {
22
22
  type MemoryStore,
23
23
  } from "./memory.js";
24
24
  import { LocalMcpBridge } from "./mcp.js";
25
- import { createModelProvider, getModelContextWindow, type ModelProviderFactory } from "./model-factory.js";
25
+ import { createModelProvider, getModelContextWindow, type ModelProviderFactory, type ProviderConfig } from "./model-factory.js";
26
26
  import { buildSkillContextWindow, loadSkillMetadata } from "./skill-context.js";
27
27
  import { streamText, type ModelMessage } from "ai";
28
28
  import { addPromptCacheBreakpoints } from "./prompt-cache.js";
@@ -37,20 +37,13 @@ import {
37
37
  matchesSlashPattern,
38
38
  normalizeRelativeScriptPattern,
39
39
  } from "./tool-policy.js";
40
- import { ToolDispatcher } from "./tool-dispatcher.js";
40
+ import { ToolDispatcher, type ToolCall, type ToolExecutionResult } from "./tool-dispatcher.js";
41
41
  import { ensureAgentIdentity } from "./agent-identity.js";
42
42
 
43
43
  export interface HarnessOptions {
44
44
  workingDir?: string;
45
45
  environment?: "development" | "staging" | "production";
46
46
  toolDefinitions?: ToolDefinition[];
47
- approvalHandler?: (request: {
48
- tool: string;
49
- input: Record<string, unknown>;
50
- runId: string;
51
- step: number;
52
- approvalId: string;
53
- }) => Promise<boolean> | boolean;
54
47
  modelProvider?: ModelProviderFactory;
55
48
  uploadStore?: UploadStore;
56
49
  }
@@ -374,18 +367,51 @@ When configuring Latitude telemetry, use **exactly** these field names:
374
367
  telemetry: {
375
368
  enabled: true,
376
369
  latitude: {
377
- apiKey: process.env.LATITUDE_API_KEY, // NOT "apiKeyEnv"
378
- projectId: process.env.LATITUDE_PROJECT_ID, // string or number
379
- path: "your/prompt-path", // optional, defaults to agent name
370
+ apiKeyEnv: "LATITUDE_API_KEY", // env var name (default)
371
+ projectIdEnv: "LATITUDE_PROJECT_ID", // env var name (default)
372
+ path: "your/prompt-path", // optional, defaults to agent name
380
373
  },
381
374
  },
382
375
  \`\`\`
383
376
 
384
- - The field is \`apiKey\` (not \`apiKeyEnv\`, \`api_key\`, or \`key\`).
385
- - The field is \`projectId\` (not \`project_id\`, \`projectID\`, or \`project\`).
377
+ - \`apiKeyEnv\` specifies the environment variable name for the Latitude API key (defaults to \`"LATITUDE_API_KEY"\`).
378
+ - \`projectIdEnv\` specifies the environment variable name for the project ID (defaults to \`"LATITUDE_PROJECT_ID"\`).
379
+ - With defaults, you only need \`telemetry: { latitude: {} }\` if the env vars are already named \`LATITUDE_API_KEY\` and \`LATITUDE_PROJECT_ID\`.
386
380
  - \`path\` must only contain letters, numbers, hyphens, underscores, dots, and slashes.
387
381
  - For a generic OTLP endpoint instead: \`telemetry: { otlp: process.env.OTEL_EXPORTER_OTLP_ENDPOINT }\`.
388
- - Always read the env vars from \`process.env\` — do not hardcode secrets in \`poncho.config.js\`.
382
+
383
+ ## Credential Configuration Pattern
384
+
385
+ All credentials in \`poncho.config.js\` use the **env var name** pattern (\`*Env\` fields). Config specifies which environment variable to read — never the secret itself. Sensible defaults mean zero config when using conventional env var names.
386
+
387
+ \`\`\`javascript
388
+ // poncho.config.js — credentials use *Env fields with defaults
389
+ export default {
390
+ // Model provider API keys (optional, defaults shown)
391
+ providers: {
392
+ anthropic: { apiKeyEnv: "ANTHROPIC_API_KEY" },
393
+ openai: { apiKeyEnv: "OPENAI_API_KEY" },
394
+ },
395
+ auth: {
396
+ required: true,
397
+ tokenEnv: "PONCHO_AUTH_TOKEN", // default
398
+ },
399
+ storage: {
400
+ provider: "upstash",
401
+ urlEnv: "UPSTASH_REDIS_REST_URL", // default (falls back to KV_REST_API_URL)
402
+ tokenEnv: "UPSTASH_REDIS_REST_TOKEN", // default (falls back to KV_REST_API_TOKEN)
403
+ },
404
+ telemetry: {
405
+ latitude: {
406
+ apiKeyEnv: "LATITUDE_API_KEY", // default
407
+ projectIdEnv: "LATITUDE_PROJECT_ID", // default
408
+ },
409
+ },
410
+ messaging: [{ platform: "slack" }], // reads SLACK_BOT_TOKEN, SLACK_SIGNING_SECRET by default
411
+ }
412
+ \`\`\`
413
+
414
+ Since all fields have defaults, you only need to specify \`*Env\` when your env var name differs from the convention.
389
415
 
390
416
  ## When users ask about customization:
391
417
 
@@ -400,6 +426,7 @@ telemetry: {
400
426
  - To scope tools to a skill: keep server config in \`poncho.config.js\`, add desired \`allowed-tools\`/ \`approval-required\` patterns in that skill's \`SKILL.md\`, and remove global \`AGENT.md\` patterns if you do not want global availability.
401
427
  - Do not invent unsupported top-level config keys (for example \`model\` in \`poncho.config.js\`). Keep existing config structure unless README/spec explicitly says otherwise.
402
428
  - Keep \`poncho.config.js\` valid JavaScript and preserve existing imports/types/comments. If there is a JSDoc type import, do not rewrite it to a different package name.
429
+ - Credentials always use \`*Env\` fields (env var names), never raw \`process.env.*\` values. For example, use \`apiKeyEnv: "MY_KEY"\` not \`apiKey: process.env.MY_KEY\`.
403
430
  - Preferred MCP config shape in \`poncho.config.js\`:
404
431
  \`mcp: [{ name: "linear", url: "https://mcp.linear.app/mcp", auth: { type: "bearer", tokenEnv: "LINEAR_TOKEN" } }]\`
405
432
  - If shell/CLI access exists, you can use \`poncho mcp add --url ... --name ... --auth-bearer-env ...\`, then \`poncho mcp tools list <server>\` and \`poncho mcp tools select <server>\`.
@@ -414,7 +441,6 @@ export class AgentHarness {
414
441
  private modelProvider: ModelProviderFactory;
415
442
  private readonly modelProviderInjected: boolean;
416
443
  private readonly dispatcher = new ToolDispatcher();
417
- private readonly approvalHandler?: HarnessOptions["approvalHandler"];
418
444
  readonly uploadStore?: UploadStore;
419
445
  private skillContextWindow = "";
420
446
  private memoryStore?: MemoryStore;
@@ -500,7 +526,6 @@ export class AgentHarness {
500
526
  this.environment = options.environment ?? "development";
501
527
  this.modelProviderInjected = !!options.modelProvider;
502
528
  this.modelProvider = options.modelProvider ?? createModelProvider("anthropic");
503
- this.approvalHandler = options.approvalHandler;
504
529
  this.uploadStore = options.uploadStore;
505
530
 
506
531
  if (options.toolDefinitions?.length) {
@@ -767,10 +792,8 @@ export class AgentHarness {
767
792
  this.registerConfiguredBuiltInTools(config);
768
793
  const provider = this.parsedAgent.frontmatter.model?.provider ?? "anthropic";
769
794
  const memoryConfig = resolveMemoryConfig(config);
770
- // Only create modelProvider if one wasn't injected (for production use)
771
- // Tests can inject a mock modelProvider via constructor options
772
795
  if (!this.modelProviderInjected) {
773
- this.modelProvider = createModelProvider(provider);
796
+ this.modelProvider = createModelProvider(provider, config?.providers);
774
797
  }
775
798
  const bridge = new LocalMcpBridge(config);
776
799
  this.mcpBridge = bridge;
@@ -802,14 +825,13 @@ export class AgentHarness {
802
825
  // Creating a new LatitudeTelemetry per run would break on the second call
803
826
  // because @opentelemetry/api silently ignores repeated global registrations.
804
827
  const telemetryEnabled = config?.telemetry?.enabled !== false;
805
- const latitudeApiKey = config?.telemetry?.latitude?.apiKey;
806
- const rawProjectId = config?.telemetry?.latitude?.projectId;
807
- const latitudeProjectId = typeof rawProjectId === 'string' ? parseInt(rawProjectId, 10) : rawProjectId;
808
828
  const latitudeBlock = config?.telemetry?.latitude;
829
+ const latApiKeyEnv = latitudeBlock?.apiKeyEnv ?? "LATITUDE_API_KEY";
830
+ const latProjectIdEnv = latitudeBlock?.projectIdEnv ?? "LATITUDE_PROJECT_ID";
831
+ const latitudeApiKey = process.env[latApiKeyEnv];
832
+ const rawProjectId = process.env[latProjectIdEnv];
833
+ const latitudeProjectId = rawProjectId ? parseInt(rawProjectId, 10) : undefined;
809
834
  if (telemetryEnabled && latitudeApiKey && latitudeProjectId) {
810
- // Surface genuine OTLP export failures. Suppress "duplicate registration"
811
- // errors that fire when the dev server re-initializes the harness — the
812
- // first registration persists and telemetry keeps working.
813
835
  diag.setLogger(
814
836
  {
815
837
  error: (msg, ...args) => {
@@ -826,16 +848,10 @@ export class AgentHarness {
826
848
  this.latitudeTelemetry = new LatitudeTelemetry(latitudeApiKey, { disableBatch: true });
827
849
  } else if (telemetryEnabled && latitudeBlock && (!latitudeApiKey || !latitudeProjectId)) {
828
850
  const missing: string[] = [];
829
- if (!latitudeApiKey) missing.push("apiKey");
830
- if (!latitudeProjectId) missing.push("projectId");
831
- const unknownKeys = Object.keys(latitudeBlock).filter(
832
- (k) => !["apiKey", "projectId", "path", "documentPath"].includes(k),
833
- );
834
- const hint = unknownKeys.length > 0
835
- ? ` (found unknown key${unknownKeys.length > 1 ? "s" : ""}: ${unknownKeys.join(", ")} – did you mean "apiKey"?)`
836
- : "";
851
+ if (!latitudeApiKey) missing.push(`${latApiKeyEnv} env var`);
852
+ if (!latitudeProjectId) missing.push(`${latProjectIdEnv} env var`);
837
853
  console.warn(
838
- `[poncho][telemetry] Latitude telemetry is configured but missing: ${missing.join(", ")}${hint}. Traces will NOT be sent.`,
854
+ `[poncho][telemetry] Latitude telemetry is configured but missing: ${missing.join(", ")}. Traces will NOT be sent.`,
839
855
  );
840
856
  }
841
857
  }
@@ -867,8 +883,8 @@ export class AgentHarness {
867
883
  const telemetry = this.latitudeTelemetry;
868
884
 
869
885
  if (telemetry) {
870
- const rawProjectId = config?.telemetry?.latitude?.projectId;
871
- const projectId = (typeof rawProjectId === 'string' ? parseInt(rawProjectId, 10) : rawProjectId) as number;
886
+ const latProjectIdEnv2 = config?.telemetry?.latitude?.projectIdEnv ?? "LATITUDE_PROJECT_ID";
887
+ const projectId = parseInt(process.env[latProjectIdEnv2] ?? "", 10) as number;
872
888
  const rawPath = config?.telemetry?.latitude?.path ?? this.parsedAgent?.frontmatter.name ?? 'agent';
873
889
  // Sanitize path for Latitude's DOCUMENT_PATH_REGEXP: /^([\w-]+\/)*([\w-.])+$/
874
890
  const path = rawPath.replace(/[^\w\-./]/g, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, '') || 'agent';
@@ -963,6 +979,7 @@ export class AgentHarness {
963
979
  ? platformMaxDurationSec * 800
964
980
  : 0;
965
981
  const messages: Message[] = [...(input.messages ?? [])];
982
+ const inputMessageCount = messages.length;
966
983
  const events: AgentEvent[] = [];
967
984
 
968
985
  const systemPrompt = renderAgentPrompt(agent, {
@@ -1024,41 +1041,43 @@ ${boundedMainMemory.trim()}`
1024
1041
  contextWindow,
1025
1042
  });
1026
1043
 
1027
- if (input.files && input.files.length > 0) {
1028
- const parts: ContentPart[] = [
1029
- { type: "text", text: input.task } satisfies TextContentPart,
1030
- ];
1031
- for (const file of input.files) {
1032
- if (this.uploadStore) {
1033
- const buf = Buffer.from(file.data, "base64");
1034
- const key = deriveUploadKey(buf, file.mediaType);
1035
- const ref = await this.uploadStore.put(key, buf, file.mediaType);
1036
- parts.push({
1037
- type: "file",
1038
- data: ref,
1039
- mediaType: file.mediaType,
1040
- filename: file.filename,
1041
- } satisfies FileContentPart);
1042
- } else {
1043
- parts.push({
1044
- type: "file",
1045
- data: file.data,
1046
- mediaType: file.mediaType,
1047
- filename: file.filename,
1048
- } satisfies FileContentPart);
1044
+ if (input.task != null) {
1045
+ if (input.files && input.files.length > 0) {
1046
+ const parts: ContentPart[] = [
1047
+ { type: "text", text: input.task } satisfies TextContentPart,
1048
+ ];
1049
+ for (const file of input.files) {
1050
+ if (this.uploadStore) {
1051
+ const buf = Buffer.from(file.data, "base64");
1052
+ const key = deriveUploadKey(buf, file.mediaType);
1053
+ const ref = await this.uploadStore.put(key, buf, file.mediaType);
1054
+ parts.push({
1055
+ type: "file",
1056
+ data: ref,
1057
+ mediaType: file.mediaType,
1058
+ filename: file.filename,
1059
+ } satisfies FileContentPart);
1060
+ } else {
1061
+ parts.push({
1062
+ type: "file",
1063
+ data: file.data,
1064
+ mediaType: file.mediaType,
1065
+ filename: file.filename,
1066
+ } satisfies FileContentPart);
1067
+ }
1049
1068
  }
1069
+ messages.push({
1070
+ role: "user",
1071
+ content: parts,
1072
+ metadata: { timestamp: now(), id: randomUUID() },
1073
+ });
1074
+ } else {
1075
+ messages.push({
1076
+ role: "user",
1077
+ content: input.task,
1078
+ metadata: { timestamp: now(), id: randomUUID() },
1079
+ });
1050
1080
  }
1051
- messages.push({
1052
- role: "user",
1053
- content: parts,
1054
- metadata: { timestamp: now(), id: randomUUID() },
1055
- });
1056
- } else {
1057
- messages.push({
1058
- role: "user",
1059
- content: input.task,
1060
- metadata: { timestamp: now(), id: randomUUID() },
1061
- });
1062
1081
  }
1063
1082
 
1064
1083
  let responseText = "";
@@ -1346,9 +1365,7 @@ ${boundedMainMemory.trim()}`
1346
1365
  const modelInstance = this.modelProvider(modelName);
1347
1366
  const cachedMessages = addPromptCacheBreakpoints(coreMessages, modelInstance);
1348
1367
 
1349
- // Stream response using Vercel AI SDK with telemetry enabled
1350
1368
  const telemetryEnabled = this.loadedConfig?.telemetry?.enabled !== false;
1351
- const latitudeApiKey = this.loadedConfig?.telemetry?.latitude?.apiKey;
1352
1369
 
1353
1370
  const result = await streamText({
1354
1371
  model: modelInstance,
@@ -1359,7 +1376,7 @@ ${boundedMainMemory.trim()}`
1359
1376
  abortSignal: input.abortSignal,
1360
1377
  ...(typeof maxTokens === "number" ? { maxTokens } : {}),
1361
1378
  experimental_telemetry: {
1362
- isEnabled: telemetryEnabled && !!latitudeApiKey,
1379
+ isEnabled: telemetryEnabled && !!this.latitudeTelemetry,
1363
1380
  },
1364
1381
  });
1365
1382
  // Stream text chunks — enforce overall run timeout per chunk.
@@ -1597,47 +1614,35 @@ ${boundedMainMemory.trim()}`
1597
1614
  input: call.input,
1598
1615
  approvalId,
1599
1616
  });
1600
- const approved = this.approvalHandler
1601
- ? await this.approvalHandler({
1602
- tool: runtimeToolName,
1603
- input: call.input,
1604
- runId,
1605
- step,
1606
- approvalId,
1607
- })
1608
- : false;
1609
- if (isCancelled()) {
1610
- yield emitCancellation();
1611
- return;
1612
- }
1613
- if (!approved) {
1614
- if (this.insideTelemetryCapture && this.latitudeTelemetry) {
1615
- const deniedSpan = this.latitudeTelemetry.span.tool({
1616
- name: runtimeToolName,
1617
- call: { id: call.id, arguments: call.input },
1618
- });
1619
- deniedSpan.end({ result: { value: "Tool execution denied by approval policy", isError: true } });
1620
- }
1621
- yield pushEvent({
1622
- type: "tool:approval:denied",
1623
- approvalId,
1624
- reason: "No approval handler granted execution",
1625
- });
1626
- yield pushEvent({
1627
- type: "tool:error",
1628
- tool: call.name,
1629
- error: "Tool execution denied by approval policy",
1630
- recoverable: true,
1631
- });
1632
- toolResultsForModel.push({
1633
- type: "tool_result",
1634
- tool_use_id: call.id,
1635
- tool_name: runtimeToolName,
1636
- content: "Tool error: Tool execution denied by approval policy",
1637
- });
1638
- continue;
1639
- }
1640
- yield pushEvent({ type: "tool:approval:granted", approvalId });
1617
+
1618
+ const assistantContent = JSON.stringify({
1619
+ text: fullText,
1620
+ tool_calls: toolCalls.map(tc => ({
1621
+ id: tc.id,
1622
+ name: exposedToolNames.get(tc.name) ?? tc.name,
1623
+ input: tc.input,
1624
+ })),
1625
+ });
1626
+ const assistantMsg: Message = {
1627
+ role: "assistant",
1628
+ content: assistantContent,
1629
+ metadata: { timestamp: now(), id: randomUUID(), step },
1630
+ };
1631
+ const deltaMessages = [...messages.slice(inputMessageCount), assistantMsg];
1632
+ yield pushEvent({
1633
+ type: "tool:approval:checkpoint",
1634
+ approvalId,
1635
+ tool: runtimeToolName,
1636
+ toolCallId: call.id,
1637
+ input: call.input,
1638
+ checkpointMessages: deltaMessages,
1639
+ pendingToolCalls: toolCalls.map(tc => ({
1640
+ id: tc.id,
1641
+ name: exposedToolNames.get(tc.name) ?? tc.name,
1642
+ input: tc.input,
1643
+ })),
1644
+ });
1645
+ return;
1641
1646
  }
1642
1647
  approvedCalls.push({
1643
1648
  id: call.id,
@@ -1790,12 +1795,87 @@ ${boundedMainMemory.trim()}`
1790
1795
  }
1791
1796
  }
1792
1797
 
1798
+ async executeTools(
1799
+ calls: ToolCall[],
1800
+ context: ToolContext,
1801
+ ): Promise<ToolExecutionResult[]> {
1802
+ return this.dispatcher.executeBatch(calls, context);
1803
+ }
1804
+
1805
+ async *continueFromToolResult(input: {
1806
+ messages: Message[];
1807
+ toolResults: Array<{ callId: string; toolName: string; result?: unknown; error?: string }>;
1808
+ conversationId?: string;
1809
+ parameters?: Record<string, unknown>;
1810
+ abortSignal?: AbortSignal;
1811
+ }): AsyncGenerator<AgentEvent> {
1812
+ const messages = [...input.messages];
1813
+ const lastMsg = messages[messages.length - 1];
1814
+ if (!lastMsg || lastMsg.role !== "assistant") {
1815
+ throw new Error("continueFromToolResult: last message must be an assistant message with tool calls");
1816
+ }
1817
+
1818
+ let allToolCalls: Array<{ id: string; name: string; input: Record<string, unknown> }> = [];
1819
+ try {
1820
+ const parsed = JSON.parse(typeof lastMsg.content === "string" ? lastMsg.content : "");
1821
+ allToolCalls = parsed.tool_calls ?? [];
1822
+ } catch {
1823
+ throw new Error("continueFromToolResult: could not parse tool calls from last assistant message");
1824
+ }
1825
+
1826
+ const providedMap = new Map(
1827
+ input.toolResults.map(r => [r.callId, r]),
1828
+ );
1829
+ const toolResultsForModel: Array<{
1830
+ type: "tool_result";
1831
+ tool_use_id: string;
1832
+ tool_name: string;
1833
+ content: string;
1834
+ }> = [];
1835
+
1836
+ for (const tc of allToolCalls) {
1837
+ const provided = providedMap.get(tc.id);
1838
+ if (provided) {
1839
+ toolResultsForModel.push({
1840
+ type: "tool_result",
1841
+ tool_use_id: tc.id,
1842
+ tool_name: provided.toolName,
1843
+ content: provided.error
1844
+ ? `Tool error: ${provided.error}`
1845
+ : JSON.stringify(provided.result ?? null),
1846
+ });
1847
+ } else {
1848
+ toolResultsForModel.push({
1849
+ type: "tool_result",
1850
+ tool_use_id: tc.id,
1851
+ tool_name: tc.name,
1852
+ content: "Tool error: Tool execution deferred (pending approval checkpoint)",
1853
+ });
1854
+ }
1855
+ }
1856
+
1857
+ messages.push({
1858
+ role: "tool",
1859
+ content: JSON.stringify(toolResultsForModel),
1860
+ metadata: { timestamp: Date.now(), id: randomUUID() },
1861
+ });
1862
+
1863
+ yield* this.runWithTelemetry({
1864
+ messages,
1865
+ conversationId: input.conversationId,
1866
+ parameters: input.parameters,
1867
+ abortSignal: input.abortSignal,
1868
+ });
1869
+ }
1870
+
1793
1871
  async runToCompletion(input: RunInput): Promise<HarnessRunOutput> {
1794
1872
  const events: AgentEvent[] = [];
1795
1873
  let runId = "";
1796
1874
  let finalResult: RunResult | undefined;
1797
1875
  const messages: Message[] = [...(input.messages ?? [])];
1798
- messages.push({ role: "user", content: input.task });
1876
+ if (input.task != null) {
1877
+ messages.push({ role: "user", content: input.task });
1878
+ }
1799
1879
 
1800
1880
  for await (const event of this.runWithTelemetry(input)) {
1801
1881
  events.push(event);
@@ -10,8 +10,8 @@
10
10
  */
11
11
 
12
12
  export interface LatitudeCaptureConfig {
13
- apiKey?: string;
14
- projectId?: string | number;
13
+ apiKeyEnv?: string;
14
+ projectIdEnv?: string;
15
15
  path?: string;
16
16
  defaultPath?: string;
17
17
  }
@@ -26,15 +26,14 @@ export class LatitudeCapture {
26
26
  private readonly path?: string;
27
27
 
28
28
  constructor(config?: LatitudeCaptureConfig) {
29
- this.apiKey = config?.apiKey ?? process.env.LATITUDE_API_KEY;
29
+ const apiKeyEnv = config?.apiKeyEnv ?? "LATITUDE_API_KEY";
30
+ this.apiKey = process.env[apiKeyEnv];
30
31
 
31
- const rawProjectId = config?.projectId ?? process.env.LATITUDE_PROJECT_ID;
32
- const projectIdNumber =
33
- typeof rawProjectId === "number"
34
- ? rawProjectId
35
- : rawProjectId
36
- ? Number.parseInt(rawProjectId, 10)
37
- : Number.NaN;
32
+ const projectIdEnv = config?.projectIdEnv ?? "LATITUDE_PROJECT_ID";
33
+ const rawProjectId = process.env[projectIdEnv];
34
+ const projectIdNumber = rawProjectId
35
+ ? Number.parseInt(rawProjectId, 10)
36
+ : Number.NaN;
38
37
  this.projectId = Number.isFinite(projectIdNumber) ? projectIdNumber : undefined;
39
38
 
40
39
  const rawPath =
package/src/memory.ts CHANGED
@@ -17,8 +17,8 @@ export interface MainMemory {
17
17
  export interface MemoryConfig {
18
18
  enabled?: boolean;
19
19
  provider?: StateProviderName;
20
- url?: string;
21
- token?: string;
20
+ urlEnv?: string;
21
+ tokenEnv?: string;
22
22
  table?: string;
23
23
  region?: string;
24
24
  ttl?: number;
@@ -495,16 +495,10 @@ export const createMemoryStore = (
495
495
  return new InMemoryMemoryStore(ttl);
496
496
  }
497
497
  if (provider === "upstash") {
498
- const url =
499
- config?.url ??
500
- process.env.UPSTASH_REDIS_REST_URL ??
501
- process.env.KV_REST_API_URL ??
502
- "";
503
- const token =
504
- config?.token ??
505
- process.env.UPSTASH_REDIS_REST_TOKEN ??
506
- process.env.KV_REST_API_TOKEN ??
507
- "";
498
+ const urlEnv = config?.urlEnv ?? (process.env.UPSTASH_REDIS_REST_URL ? "UPSTASH_REDIS_REST_URL" : "KV_REST_API_URL");
499
+ const tokenEnv = config?.tokenEnv ?? (process.env.UPSTASH_REDIS_REST_TOKEN ? "UPSTASH_REDIS_REST_TOKEN" : "KV_REST_API_TOKEN");
500
+ const url = process.env[urlEnv] ?? "";
501
+ const token = process.env[tokenEnv] ?? "";
508
502
  if (url && token) {
509
503
  return new UpstashMemoryStore({
510
504
  baseUrl: url,
@@ -516,7 +510,8 @@ export const createMemoryStore = (
516
510
  return new InMemoryMemoryStore(ttl);
517
511
  }
518
512
  if (provider === "redis") {
519
- const url = config?.url ?? process.env.REDIS_URL ?? "";
513
+ const urlEnv = config?.urlEnv ?? "REDIS_URL";
514
+ const url = process.env[urlEnv] ?? "";
520
515
  if (url) {
521
516
  return new RedisMemoryStore({
522
517
  url,
@@ -49,23 +49,30 @@ export const getModelContextWindow = (modelName: string): number => {
49
49
  return best ? MODEL_CONTEXT_WINDOWS[best]! : DEFAULT_CONTEXT_WINDOW;
50
50
  };
51
51
 
52
+ export interface ProviderConfig {
53
+ openai?: { apiKeyEnv?: string };
54
+ anthropic?: { apiKeyEnv?: string };
55
+ }
56
+
52
57
  /**
53
- * Creates a model provider factory for the specified AI provider
54
- * @param provider - The provider name ('openai' or 'anthropic')
55
- * @returns A function that takes a model name and returns a LanguageModel instance
58
+ * Creates a model provider factory for the specified AI provider.
59
+ * API keys are read from environment variables; override the env var
60
+ * name via the `providers` config in `poncho.config.js`.
56
61
  */
57
- export const createModelProvider = (provider?: string): ModelProviderFactory => {
62
+ export const createModelProvider = (provider?: string, config?: ProviderConfig): ModelProviderFactory => {
58
63
  const normalized = (provider ?? "anthropic").toLowerCase();
59
64
 
60
65
  if (normalized === "openai") {
66
+ const apiKeyEnv = config?.openai?.apiKeyEnv ?? "OPENAI_API_KEY";
61
67
  const openai = createOpenAI({
62
- apiKey: process.env.OPENAI_API_KEY,
68
+ apiKey: process.env[apiKeyEnv],
63
69
  });
64
70
  return (modelName: string) => openai(modelName);
65
71
  }
66
72
 
73
+ const apiKeyEnv = config?.anthropic?.apiKeyEnv ?? "ANTHROPIC_API_KEY";
67
74
  const anthropic = createAnthropic({
68
- apiKey: process.env.ANTHROPIC_API_KEY,
75
+ apiKey: process.env[apiKeyEnv],
69
76
  });
70
77
  return (modelName: string) => anthropic(modelName);
71
78
  };
package/src/state.ts CHANGED
@@ -30,7 +30,11 @@ export interface Conversation {
30
30
  approvalId: string;
31
31
  runId: string;
32
32
  tool: string;
33
+ toolCallId?: string;
33
34
  input: Record<string, unknown>;
35
+ checkpointMessages?: Message[];
36
+ baseMessageCount?: number;
37
+ pendingToolCalls?: Array<{ id: string; name: string; input: Record<string, unknown> }>;
34
38
  }>;
35
39
  ownerId: string;
36
40
  tenantId: string | null;
@@ -57,8 +61,8 @@ export type StateProviderName =
57
61
  export interface StateConfig {
58
62
  provider?: StateProviderName;
59
63
  ttl?: number;
60
- url?: string;
61
- token?: string;
64
+ urlEnv?: string;
65
+ tokenEnv?: string;
62
66
  table?: string;
63
67
  region?: string;
64
68
  }
@@ -1162,15 +1166,18 @@ export const createStateStore = (
1162
1166
  return new InMemoryStateStore(ttl);
1163
1167
  }
1164
1168
  if (provider === "upstash") {
1165
- const url = config?.url ?? process.env.UPSTASH_REDIS_REST_URL ?? "";
1166
- const token = config?.token ?? process.env.UPSTASH_REDIS_REST_TOKEN ?? "";
1169
+ const urlEnv = config?.urlEnv ?? (process.env.UPSTASH_REDIS_REST_URL ? "UPSTASH_REDIS_REST_URL" : "KV_REST_API_URL");
1170
+ const tokenEnv = config?.tokenEnv ?? (process.env.UPSTASH_REDIS_REST_TOKEN ? "UPSTASH_REDIS_REST_TOKEN" : "KV_REST_API_TOKEN");
1171
+ const url = process.env[urlEnv] ?? "";
1172
+ const token = process.env[tokenEnv] ?? "";
1167
1173
  if (url && token) {
1168
1174
  return new UpstashStateStore(url, token, ttl);
1169
1175
  }
1170
1176
  return new InMemoryStateStore(ttl);
1171
1177
  }
1172
1178
  if (provider === "redis") {
1173
- const url = config?.url ?? process.env.REDIS_URL ?? "";
1179
+ const urlEnv = config?.urlEnv ?? "REDIS_URL";
1180
+ const url = process.env[urlEnv] ?? "";
1174
1181
  if (url) {
1175
1182
  return new RedisLikeStateStore(url, ttl);
1176
1183
  }
@@ -1200,19 +1207,18 @@ export const createConversationStore = (
1200
1207
  return new InMemoryConversationStore(ttl);
1201
1208
  }
1202
1209
  if (provider === "upstash") {
1203
- const url =
1204
- config?.url ??
1205
- (process.env.UPSTASH_REDIS_REST_URL || process.env.KV_REST_API_URL || "");
1206
- const token =
1207
- config?.token ??
1208
- (process.env.UPSTASH_REDIS_REST_TOKEN || process.env.KV_REST_API_TOKEN || "");
1210
+ const urlEnv = config?.urlEnv ?? (process.env.UPSTASH_REDIS_REST_URL ? "UPSTASH_REDIS_REST_URL" : "KV_REST_API_URL");
1211
+ const tokenEnv = config?.tokenEnv ?? (process.env.UPSTASH_REDIS_REST_TOKEN ? "UPSTASH_REDIS_REST_TOKEN" : "KV_REST_API_TOKEN");
1212
+ const url = process.env[urlEnv] ?? "";
1213
+ const token = process.env[tokenEnv] ?? "";
1209
1214
  if (url && token) {
1210
1215
  return new UpstashConversationStore(url, token, workingDir, ttl, options?.agentId);
1211
1216
  }
1212
1217
  return new InMemoryConversationStore(ttl);
1213
1218
  }
1214
1219
  if (provider === "redis") {
1215
- const url = config?.url ?? process.env.REDIS_URL ?? "";
1220
+ const urlEnv = config?.urlEnv ?? "REDIS_URL";
1221
+ const url = process.env[urlEnv] ?? "";
1216
1222
  if (url) {
1217
1223
  return new RedisLikeConversationStore(url, workingDir, ttl, options?.agentId);
1218
1224
  }
package/src/telemetry.ts CHANGED
@@ -4,8 +4,8 @@ export interface TelemetryConfig {
4
4
  enabled?: boolean;
5
5
  otlp?: string;
6
6
  latitude?: {
7
- apiKey?: string;
8
- projectId?: string | number;
7
+ apiKeyEnv?: string;
8
+ projectIdEnv?: string;
9
9
  path?: string;
10
10
  documentPath?: string;
11
11
  };
@@ -15,7 +15,7 @@ describe("telemetry emitter", () => {
15
15
 
16
16
  const emitter = new TelemetryEmitter({
17
17
  otlp: "https://otel.example.com/v1/logs",
18
- latitude: { apiKey: "lat_test" },
18
+ latitude: { apiKeyEnv: "LATITUDE_API_KEY" },
19
19
  });
20
20
 
21
21
  await expect(
@@ -32,8 +32,8 @@ describe("telemetry emitter", () => {
32
32
 
33
33
  const emitter = new TelemetryEmitter({
34
34
  latitude: {
35
- apiKey: "lat_test",
36
- projectId: "proj_123",
35
+ apiKeyEnv: "LATITUDE_API_KEY",
36
+ projectIdEnv: "LATITUDE_PROJECT_ID",
37
37
  documentPath: "agents/support-agent/AGENT.md",
38
38
  },
39
39
  });