@poncho-ai/harness 0.11.2 → 0.13.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poncho-ai/harness",
3
- "version": "0.11.2",
3
+ "version": "0.13.0",
4
4
  "description": "Agent execution runtime - conversation loop, tool dispatch, streaming",
5
5
  "repository": {
6
6
  "type": "git",
@@ -30,7 +30,7 @@
30
30
  "redis": "^5.10.0",
31
31
  "yaml": "^2.4.0",
32
32
  "zod": "^3.22.0",
33
- "@poncho-ai/sdk": "0.6.0"
33
+ "@poncho-ai/sdk": "1.0.0"
34
34
  },
35
35
  "devDependencies": {
36
36
  "@types/mustache": "^4.2.6",
@@ -22,6 +22,12 @@ export interface AgentLimitsConfig {
22
22
  timeout?: number;
23
23
  }
24
24
 
25
+ export interface CronJobConfig {
26
+ schedule: string;
27
+ task: string;
28
+ timezone?: string;
29
+ }
30
+
25
31
  export interface AgentFrontmatter {
26
32
  name: string;
27
33
  id?: string;
@@ -36,6 +42,7 @@ export interface AgentFrontmatter {
36
42
  mcp?: string[];
37
43
  scripts?: string[];
38
44
  };
45
+ cron?: Record<string, CronJobConfig>;
39
46
  }
40
47
 
41
48
  export interface ParsedAgent {
@@ -63,6 +70,74 @@ const asRecord = (value: unknown): Record<string, unknown> =>
63
70
  const asNumberOrUndefined = (value: unknown): number | undefined =>
64
71
  typeof value === "number" ? value : undefined;
65
72
 
73
+ const CRON_EXPRESSION_PATTERN = /^(\S+\s+){4}\S+$/;
74
+
75
+ const validateCronExpression = (expr: string, path: string): void => {
76
+ if (!CRON_EXPRESSION_PATTERN.test(expr.trim())) {
77
+ throw new Error(
78
+ `Invalid cron expression at ${path}: "${expr}". Expected 5-field cron format (minute hour day month weekday).`,
79
+ );
80
+ }
81
+ };
82
+
83
+ const KNOWN_TIMEZONES: Set<string> | null = (() => {
84
+ try {
85
+ return new Set(Intl.supportedValuesOf("timeZone"));
86
+ } catch {
87
+ return null;
88
+ }
89
+ })();
90
+
91
+ const validateTimezone = (tz: string, path: string): void => {
92
+ if (KNOWN_TIMEZONES && !KNOWN_TIMEZONES.has(tz)) {
93
+ throw new Error(
94
+ `Invalid timezone at ${path}: "${tz}". Expected an IANA timezone string (e.g. "America/New_York", "UTC").`,
95
+ );
96
+ }
97
+ };
98
+
99
+ const parseCronJobs = (
100
+ value: unknown,
101
+ ): Record<string, CronJobConfig> | undefined => {
102
+ const raw = asRecord(value);
103
+ const keys = Object.keys(raw);
104
+ if (keys.length === 0) return undefined;
105
+
106
+ const jobs: Record<string, CronJobConfig> = {};
107
+ for (const jobName of keys) {
108
+ const jobValue = asRecord(raw[jobName]);
109
+ const path = `AGENT.md frontmatter cron.${jobName}`;
110
+
111
+ if (typeof jobValue.schedule !== "string" || jobValue.schedule.trim() === "") {
112
+ throw new Error(
113
+ `Invalid ${path}: "schedule" is required and must be a non-empty string.`,
114
+ );
115
+ }
116
+ if (typeof jobValue.task !== "string" || jobValue.task.trim() === "") {
117
+ throw new Error(
118
+ `Invalid ${path}: "task" is required and must be a non-empty string.`,
119
+ );
120
+ }
121
+
122
+ validateCronExpression(jobValue.schedule, path);
123
+
124
+ const timezone =
125
+ typeof jobValue.timezone === "string" && jobValue.timezone.trim()
126
+ ? jobValue.timezone.trim()
127
+ : undefined;
128
+ if (timezone) {
129
+ validateTimezone(timezone, path);
130
+ }
131
+
132
+ jobs[jobName] = {
133
+ schedule: jobValue.schedule.trim(),
134
+ task: jobValue.task,
135
+ timezone,
136
+ };
137
+ }
138
+ return jobs;
139
+ };
140
+
66
141
  export const parseAgentMarkdown = (content: string): ParsedAgent => {
67
142
  const match = content.match(FRONTMATTER_PATTERN);
68
143
 
@@ -175,6 +250,7 @@ export const parseAgentMarkdown = (content: string): ParsedAgent => {
175
250
  : undefined,
176
251
  }
177
252
  : undefined,
253
+ cron: parseCronJobs(parsed.cron),
178
254
  };
179
255
 
180
256
  return {
package/src/config.ts CHANGED
@@ -23,6 +23,15 @@ export interface StorageConfig {
23
23
  };
24
24
  }
25
25
 
26
+ export interface UploadsConfig {
27
+ provider?: "local" | "vercel-blob" | "s3";
28
+ /** Vercel Blob access mode. Must match the store's configuration. Defaults to "public". */
29
+ access?: "public" | "private";
30
+ bucket?: string;
31
+ region?: string;
32
+ endpoint?: string;
33
+ }
34
+
26
35
  export type BuiltInToolToggles = {
27
36
  list_directory?: boolean;
28
37
  read_file?: boolean;
@@ -67,6 +76,7 @@ export interface PonchoConfig extends McpConfig {
67
76
  /** Extra directories (relative to project root) to scan for skills.
68
77
  * `skills/` and `.poncho/skills/` are always scanned. */
69
78
  skillPaths?: string[];
79
+ uploads?: UploadsConfig;
70
80
  build?: {
71
81
  vercel?: Record<string, unknown>;
72
82
  docker?: Record<string, unknown>;
package/src/harness.ts CHANGED
@@ -1,13 +1,19 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import type {
3
3
  AgentEvent,
4
+ ContentPart,
5
+ FileContentPart,
4
6
  Message,
5
7
  RunInput,
6
8
  RunResult,
9
+ TextContentPart,
7
10
  ToolContext,
8
11
  ToolDefinition,
9
12
  } from "@poncho-ai/sdk";
10
- import { parseAgentFile, renderAgentPrompt, type ParsedAgent } from "./agent-parser.js";
13
+ import { getTextContent } from "@poncho-ai/sdk";
14
+ import type { UploadStore } from "./upload-store.js";
15
+ import { PONCHO_UPLOAD_SCHEME, deriveUploadKey } from "./upload-store.js";
16
+ import { parseAgentFile, renderAgentPrompt, type ParsedAgent, type AgentFrontmatter } from "./agent-parser.js";
11
17
  import { loadPonchoConfig, resolveMemoryConfig, type PonchoConfig } from "./config.js";
12
18
  import { createDefaultTools, createWriteTool } from "./default-tools.js";
13
19
  import {
@@ -44,6 +50,7 @@ export interface HarnessOptions {
44
50
  approvalId: string;
45
51
  }) => Promise<boolean> | boolean;
46
52
  modelProvider?: ModelProviderFactory;
53
+ uploadStore?: UploadStore;
47
54
  }
48
55
 
49
56
  export interface HarnessRunOutput {
@@ -229,6 +236,25 @@ You can extend your own capabilities by creating custom JavaScript/TypeScript sc
229
236
  - Script entries outside \`./scripts/\` must also appear in \`allowed-tools\`.
230
237
  - Keep MCP server connection details (\`url\`, auth env vars) in \`poncho.config.js\` only.
231
238
 
239
+ ## Cron Jobs
240
+
241
+ Users can define scheduled tasks in \`AGENT.md\` frontmatter:
242
+
243
+ \`\`\`yaml
244
+ cron:
245
+ daily-report:
246
+ schedule: "0 9 * * *" # Standard 5-field cron expression
247
+ timezone: "America/New_York" # Optional IANA timezone (default: UTC)
248
+ task: "Generate the daily sales report"
249
+ \`\`\`
250
+
251
+ - Each cron job triggers an autonomous agent run with the specified task, creating a fresh conversation.
252
+ - In \`poncho dev\`, jobs run via an in-process scheduler and appear in the web UI sidebar (prefixed with \`[cron]\`).
253
+ - For Vercel: \`poncho build vercel\` generates \`vercel.json\` cron entries. Set \`CRON_SECRET\` = \`PONCHO_AUTH_TOKEN\`.
254
+ - Jobs can also be triggered manually: \`GET /api/cron/<jobName>\`.
255
+ - To carry context across cron runs, enable memory.
256
+ - **IMPORTANT**: When adding a new cron job, always PRESERVE all existing cron jobs. Never remove or overwrite existing jobs unless the user explicitly asks you to replace or delete them. Read the full current \`cron:\` block before editing, and append the new job alongside the existing ones.
257
+
232
258
  ## When users ask about customization:
233
259
 
234
260
  - Explain and edit \`poncho.config.js\` for model/provider, storage+memory, auth, telemetry, and MCP settings.
@@ -257,6 +283,7 @@ export class AgentHarness {
257
283
  private readonly modelProviderInjected: boolean;
258
284
  private readonly dispatcher = new ToolDispatcher();
259
285
  private readonly approvalHandler?: HarnessOptions["approvalHandler"];
286
+ readonly uploadStore?: UploadStore;
260
287
  private skillContextWindow = "";
261
288
  private memoryStore?: MemoryStore;
262
289
  private loadedConfig?: PonchoConfig;
@@ -329,12 +356,17 @@ export class AgentHarness {
329
356
  this.modelProviderInjected = !!options.modelProvider;
330
357
  this.modelProvider = options.modelProvider ?? createModelProvider("anthropic");
331
358
  this.approvalHandler = options.approvalHandler;
359
+ this.uploadStore = options.uploadStore;
332
360
 
333
361
  if (options.toolDefinitions?.length) {
334
362
  this.dispatcher.registerMany(options.toolDefinitions);
335
363
  }
336
364
  }
337
365
 
366
+ get frontmatter(): AgentFrontmatter | undefined {
367
+ return this.parsedAgent?.frontmatter;
368
+ }
369
+
338
370
  private listActiveSkills(): string[] {
339
371
  return [...this.activeSkillNames].sort();
340
372
  }
@@ -706,6 +738,10 @@ export class AgentHarness {
706
738
  const start = now();
707
739
  const maxSteps = agent.frontmatter.limits?.maxSteps ?? 50;
708
740
  const timeoutMs = (agent.frontmatter.limits?.timeout ?? 300) * 1000;
741
+ const platformMaxDurationSec = Number(process.env.PONCHO_MAX_DURATION) || 0;
742
+ const softDeadlineMs = platformMaxDurationSec > 0
743
+ ? platformMaxDurationSec * 800
744
+ : 0;
709
745
  const messages: Message[] = [...(input.messages ?? [])];
710
746
  const events: AgentEvent[] = [];
711
747
 
@@ -763,11 +799,42 @@ ${boundedMainMemory.trim()}`
763
799
  agentId: agent.frontmatter.id ?? agent.frontmatter.name,
764
800
  });
765
801
 
766
- messages.push({
767
- role: "user",
768
- content: input.task,
769
- metadata: { timestamp: now(), id: randomUUID() },
770
- });
802
+ if (input.files && input.files.length > 0) {
803
+ const parts: ContentPart[] = [
804
+ { type: "text", text: input.task } satisfies TextContentPart,
805
+ ];
806
+ for (const file of input.files) {
807
+ if (this.uploadStore) {
808
+ const buf = Buffer.from(file.data, "base64");
809
+ const key = deriveUploadKey(buf, file.mediaType);
810
+ const ref = await this.uploadStore.put(key, buf, file.mediaType);
811
+ parts.push({
812
+ type: "file",
813
+ data: ref,
814
+ mediaType: file.mediaType,
815
+ filename: file.filename,
816
+ } satisfies FileContentPart);
817
+ } else {
818
+ parts.push({
819
+ type: "file",
820
+ data: file.data,
821
+ mediaType: file.mediaType,
822
+ filename: file.filename,
823
+ } satisfies FileContentPart);
824
+ }
825
+ }
826
+ messages.push({
827
+ role: "user",
828
+ content: parts,
829
+ metadata: { timestamp: now(), id: randomUUID() },
830
+ });
831
+ } else {
832
+ messages.push({
833
+ role: "user",
834
+ content: input.task,
835
+ metadata: { timestamp: now(), id: randomUUID() },
836
+ });
837
+ }
771
838
 
772
839
  let responseText = "";
773
840
  let totalInputTokens = 0;
@@ -791,6 +858,19 @@ ${boundedMainMemory.trim()}`
791
858
  });
792
859
  return;
793
860
  }
861
+ if (softDeadlineMs > 0 && now() - start > softDeadlineMs) {
862
+ const result: RunResult = {
863
+ status: "completed",
864
+ response: responseText,
865
+ steps: step - 1,
866
+ tokens: { input: totalInputTokens, output: totalOutputTokens, cached: 0 },
867
+ duration: now() - start,
868
+ continuation: true,
869
+ maxSteps,
870
+ };
871
+ yield pushEvent({ type: "run:completed", runId, result });
872
+ return;
873
+ }
794
874
 
795
875
  const stepStart = now();
796
876
  yield pushEvent({ type: "step:started", step });
@@ -818,13 +898,13 @@ ${boundedMainMemory.trim()}`
818
898
  }
819
899
 
820
900
  // Convert messages to ModelMessage format
821
- const coreMessages: ModelMessage[] = trimMessageWindow(messages).flatMap(
822
- (msg): ModelMessage[] => {
901
+ const convertMessage = async (msg: Message): Promise<ModelMessage[]> => {
823
902
  if (msg.role === "tool") {
824
903
  // Tool messages are provider-sensitive; skip malformed historical records
825
904
  // instead of failing the entire run continuation.
905
+ const textContent = typeof msg.content === "string" ? msg.content : getTextContent(msg);
826
906
  try {
827
- const parsed: unknown = JSON.parse(msg.content);
907
+ const parsed: unknown = JSON.parse(textContent);
828
908
  if (!Array.isArray(parsed)) {
829
909
  return [];
830
910
  }
@@ -881,12 +961,13 @@ ${boundedMainMemory.trim()}`
881
961
  if (msg.role === "assistant") {
882
962
  // Assistant messages may contain serialized tool calls from previous runs.
883
963
  // Keep only valid tool-call records to avoid broken continuation payloads.
964
+ const assistantText = typeof msg.content === "string" ? msg.content : getTextContent(msg);
884
965
  try {
885
- const parsed: unknown = JSON.parse(msg.content);
966
+ const parsed: unknown = JSON.parse(assistantText);
886
967
  if (typeof parsed === "object" && parsed !== null) {
887
968
  const parsedRecord = parsed as { text?: unknown; tool_calls?: unknown };
888
969
  if (!Array.isArray(parsedRecord.tool_calls)) {
889
- return [{ role: "assistant" as const, content: msg.content }];
970
+ return [{ role: "assistant" as const, content: assistantText }];
890
971
  }
891
972
  const textPart = typeof parsedRecord.text === "string" ? parsedRecord.text : "";
892
973
  const validToolCalls = parsedRecord.tool_calls
@@ -922,18 +1003,115 @@ ${boundedMainMemory.trim()}`
922
1003
  } catch {
923
1004
  // Not JSON, treat as regular assistant text.
924
1005
  }
925
- return [{ role: "assistant" as const, content: msg.content }];
1006
+ return [{ role: "assistant" as const, content: assistantText }];
926
1007
  }
927
1008
 
928
- if (msg.role === "user" || msg.role === "system") {
1009
+ if (msg.role === "system") {
929
1010
  return [{
930
- role: msg.role,
931
- content: msg.content,
1011
+ role: "system" as const,
1012
+ content: typeof msg.content === "string" ? msg.content : getTextContent(msg),
932
1013
  }];
933
1014
  }
934
1015
 
1016
+ if (msg.role === "user") {
1017
+ if (typeof msg.content === "string") {
1018
+ return [{ role: "user" as const, content: msg.content }];
1019
+ }
1020
+ // Convert ContentPart[] to Vercel AI SDK UserContent.
1021
+ // Only specific media types can be sent as binary attachments —
1022
+ // everything else is inlined as text or skipped gracefully.
1023
+ const MODEL_IMAGE_TYPES = new Set([
1024
+ "image/jpeg", "image/png", "image/gif", "image/webp",
1025
+ ]);
1026
+ const MODEL_FILE_TYPES = new Set([
1027
+ "application/pdf",
1028
+ ]);
1029
+ const isTextBasedMime = (mt: string): boolean =>
1030
+ mt.startsWith("text/") ||
1031
+ mt === "application/json" ||
1032
+ mt === "application/xml" ||
1033
+ mt === "application/x-yaml" ||
1034
+ mt.endsWith("+json") ||
1035
+ mt.endsWith("+xml");
1036
+
1037
+ const userContent = await Promise.all(
1038
+ msg.content.map(async (part) => {
1039
+ if (part.type === "text") {
1040
+ return { type: "text" as const, text: part.text };
1041
+ }
1042
+
1043
+ const isSupportedImage = MODEL_IMAGE_TYPES.has(part.mediaType);
1044
+ const isSupportedFile = MODEL_FILE_TYPES.has(part.mediaType);
1045
+
1046
+ // Text-based files: inline the content so the model can read it
1047
+ if (!isSupportedImage && !isSupportedFile && isTextBasedMime(part.mediaType)) {
1048
+ let textContent: string;
1049
+ try {
1050
+ if (part.data.startsWith(PONCHO_UPLOAD_SCHEME) && this.uploadStore) {
1051
+ const buf = await this.uploadStore.get(part.data);
1052
+ textContent = buf.toString("utf8");
1053
+ } else if (part.data.startsWith("https://") || part.data.startsWith("http://")) {
1054
+ const resp = await fetch(part.data);
1055
+ textContent = await resp.text();
1056
+ } else {
1057
+ textContent = Buffer.from(part.data, "base64").toString("utf8");
1058
+ }
1059
+ } catch {
1060
+ textContent = "(could not read file)";
1061
+ }
1062
+ const label = part.filename ?? part.mediaType;
1063
+ return { type: "text" as const, text: `[File: ${label}]\n${textContent}` };
1064
+ }
1065
+
1066
+ // Unsupported binary formats (e.g. AVIF, HEIC, MP4): skip with a note
1067
+ if (!isSupportedImage && !isSupportedFile) {
1068
+ const label = part.filename ?? part.mediaType;
1069
+ return {
1070
+ type: "text" as const,
1071
+ text: `[Attached file: ${label} (${part.mediaType}) — this format is not supported by the model and was skipped]`,
1072
+ };
1073
+ }
1074
+
1075
+ // Always resolve to base64 so the model doesn't need to
1076
+ // fetch URLs itself (which fails for private blob stores).
1077
+ let resolvedData: string;
1078
+ if (part.data.startsWith(PONCHO_UPLOAD_SCHEME) && this.uploadStore) {
1079
+ const buf = await this.uploadStore.get(part.data);
1080
+ resolvedData = buf.toString("base64");
1081
+ } else if (part.data.startsWith("https://") || part.data.startsWith("http://")) {
1082
+ if (this.uploadStore) {
1083
+ const buf = await this.uploadStore.get(part.data);
1084
+ resolvedData = buf.toString("base64");
1085
+ } else {
1086
+ const resp = await fetch(part.data);
1087
+ resolvedData = Buffer.from(await resp.arrayBuffer()).toString("base64");
1088
+ }
1089
+ } else {
1090
+ resolvedData = part.data;
1091
+ }
1092
+ if (isSupportedImage) {
1093
+ return {
1094
+ type: "image" as const,
1095
+ image: resolvedData,
1096
+ mediaType: part.mediaType,
1097
+ };
1098
+ }
1099
+ return {
1100
+ type: "file" as const,
1101
+ data: resolvedData,
1102
+ mediaType: part.mediaType,
1103
+ filename: part.filename,
1104
+ };
1105
+ }),
1106
+ );
1107
+ return [{ role: "user" as const, content: userContent }];
1108
+ }
1109
+
935
1110
  return [];
936
- });
1111
+ };
1112
+ const coreMessages: ModelMessage[] = (
1113
+ await Promise.all(trimMessageWindow(messages).map(convertMessage))
1114
+ ).flat();
937
1115
 
938
1116
  const modelName = agent.frontmatter.model?.name ?? "claude-opus-4-5";
939
1117
  const temperature = agent.frontmatter.model?.temperature ?? 0.2;
@@ -1319,14 +1497,27 @@ ${boundedMainMemory.trim()}`
1319
1497
  }
1320
1498
  }
1321
1499
 
1322
- yield {
1323
- type: "run:error",
1324
- runId,
1325
- error: {
1326
- code: "MAX_STEPS_EXCEEDED",
1327
- message: `Run reached maximum of ${maxSteps} steps`,
1328
- },
1329
- };
1500
+ if (softDeadlineMs > 0) {
1501
+ const result: RunResult = {
1502
+ status: "completed",
1503
+ response: responseText,
1504
+ steps: maxSteps,
1505
+ tokens: { input: totalInputTokens, output: totalOutputTokens, cached: 0 },
1506
+ duration: now() - start,
1507
+ continuation: true,
1508
+ maxSteps,
1509
+ };
1510
+ yield pushEvent({ type: "run:completed", runId, result });
1511
+ } else {
1512
+ yield pushEvent({
1513
+ type: "run:error",
1514
+ runId,
1515
+ error: {
1516
+ code: "MAX_STEPS_EXCEEDED",
1517
+ message: `Run reached maximum of ${maxSteps} steps`,
1518
+ },
1519
+ });
1520
+ }
1330
1521
  }
1331
1522
 
1332
1523
  async runToCompletion(input: RunInput): Promise<HarnessRunOutput> {
package/src/index.ts CHANGED
@@ -11,6 +11,7 @@ export * from "./schema-converter.js";
11
11
  export * from "./skill-context.js";
12
12
  export * from "./skill-tools.js";
13
13
  export * from "./state.js";
14
+ export * from "./upload-store.js";
14
15
  export * from "./telemetry.js";
15
16
  export * from "./tool-dispatcher.js";
16
17
  export { defineTool } from "@poncho-ai/sdk";