@poncho-ai/harness 0.11.2 → 0.12.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.12.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",
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,12 +1,18 @@
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";
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";
10
16
  import { parseAgentFile, renderAgentPrompt, type ParsedAgent } from "./agent-parser.js";
11
17
  import { loadPonchoConfig, resolveMemoryConfig, type PonchoConfig } from "./config.js";
12
18
  import { createDefaultTools, createWriteTool } from "./default-tools.js";
@@ -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 {
@@ -257,6 +264,7 @@ export class AgentHarness {
257
264
  private readonly modelProviderInjected: boolean;
258
265
  private readonly dispatcher = new ToolDispatcher();
259
266
  private readonly approvalHandler?: HarnessOptions["approvalHandler"];
267
+ readonly uploadStore?: UploadStore;
260
268
  private skillContextWindow = "";
261
269
  private memoryStore?: MemoryStore;
262
270
  private loadedConfig?: PonchoConfig;
@@ -329,6 +337,7 @@ export class AgentHarness {
329
337
  this.modelProviderInjected = !!options.modelProvider;
330
338
  this.modelProvider = options.modelProvider ?? createModelProvider("anthropic");
331
339
  this.approvalHandler = options.approvalHandler;
340
+ this.uploadStore = options.uploadStore;
332
341
 
333
342
  if (options.toolDefinitions?.length) {
334
343
  this.dispatcher.registerMany(options.toolDefinitions);
@@ -706,6 +715,10 @@ export class AgentHarness {
706
715
  const start = now();
707
716
  const maxSteps = agent.frontmatter.limits?.maxSteps ?? 50;
708
717
  const timeoutMs = (agent.frontmatter.limits?.timeout ?? 300) * 1000;
718
+ const platformMaxDurationSec = Number(process.env.PONCHO_MAX_DURATION) || 0;
719
+ const softDeadlineMs = platformMaxDurationSec > 0
720
+ ? platformMaxDurationSec * 800
721
+ : 0;
709
722
  const messages: Message[] = [...(input.messages ?? [])];
710
723
  const events: AgentEvent[] = [];
711
724
 
@@ -763,11 +776,42 @@ ${boundedMainMemory.trim()}`
763
776
  agentId: agent.frontmatter.id ?? agent.frontmatter.name,
764
777
  });
765
778
 
766
- messages.push({
767
- role: "user",
768
- content: input.task,
769
- metadata: { timestamp: now(), id: randomUUID() },
770
- });
779
+ if (input.files && input.files.length > 0) {
780
+ const parts: ContentPart[] = [
781
+ { type: "text", text: input.task } satisfies TextContentPart,
782
+ ];
783
+ for (const file of input.files) {
784
+ if (this.uploadStore) {
785
+ const buf = Buffer.from(file.data, "base64");
786
+ const key = deriveUploadKey(buf, file.mediaType);
787
+ const ref = await this.uploadStore.put(key, buf, file.mediaType);
788
+ parts.push({
789
+ type: "file",
790
+ data: ref,
791
+ mediaType: file.mediaType,
792
+ filename: file.filename,
793
+ } satisfies FileContentPart);
794
+ } else {
795
+ parts.push({
796
+ type: "file",
797
+ data: file.data,
798
+ mediaType: file.mediaType,
799
+ filename: file.filename,
800
+ } satisfies FileContentPart);
801
+ }
802
+ }
803
+ messages.push({
804
+ role: "user",
805
+ content: parts,
806
+ metadata: { timestamp: now(), id: randomUUID() },
807
+ });
808
+ } else {
809
+ messages.push({
810
+ role: "user",
811
+ content: input.task,
812
+ metadata: { timestamp: now(), id: randomUUID() },
813
+ });
814
+ }
771
815
 
772
816
  let responseText = "";
773
817
  let totalInputTokens = 0;
@@ -791,6 +835,19 @@ ${boundedMainMemory.trim()}`
791
835
  });
792
836
  return;
793
837
  }
838
+ if (softDeadlineMs > 0 && now() - start > softDeadlineMs) {
839
+ const result: RunResult = {
840
+ status: "completed",
841
+ response: responseText,
842
+ steps: step - 1,
843
+ tokens: { input: totalInputTokens, output: totalOutputTokens, cached: 0 },
844
+ duration: now() - start,
845
+ continuation: true,
846
+ maxSteps,
847
+ };
848
+ yield pushEvent({ type: "run:completed", runId, result });
849
+ return;
850
+ }
794
851
 
795
852
  const stepStart = now();
796
853
  yield pushEvent({ type: "step:started", step });
@@ -818,13 +875,13 @@ ${boundedMainMemory.trim()}`
818
875
  }
819
876
 
820
877
  // Convert messages to ModelMessage format
821
- const coreMessages: ModelMessage[] = trimMessageWindow(messages).flatMap(
822
- (msg): ModelMessage[] => {
878
+ const convertMessage = async (msg: Message): Promise<ModelMessage[]> => {
823
879
  if (msg.role === "tool") {
824
880
  // Tool messages are provider-sensitive; skip malformed historical records
825
881
  // instead of failing the entire run continuation.
882
+ const textContent = typeof msg.content === "string" ? msg.content : getTextContent(msg);
826
883
  try {
827
- const parsed: unknown = JSON.parse(msg.content);
884
+ const parsed: unknown = JSON.parse(textContent);
828
885
  if (!Array.isArray(parsed)) {
829
886
  return [];
830
887
  }
@@ -881,12 +938,13 @@ ${boundedMainMemory.trim()}`
881
938
  if (msg.role === "assistant") {
882
939
  // Assistant messages may contain serialized tool calls from previous runs.
883
940
  // Keep only valid tool-call records to avoid broken continuation payloads.
941
+ const assistantText = typeof msg.content === "string" ? msg.content : getTextContent(msg);
884
942
  try {
885
- const parsed: unknown = JSON.parse(msg.content);
943
+ const parsed: unknown = JSON.parse(assistantText);
886
944
  if (typeof parsed === "object" && parsed !== null) {
887
945
  const parsedRecord = parsed as { text?: unknown; tool_calls?: unknown };
888
946
  if (!Array.isArray(parsedRecord.tool_calls)) {
889
- return [{ role: "assistant" as const, content: msg.content }];
947
+ return [{ role: "assistant" as const, content: assistantText }];
890
948
  }
891
949
  const textPart = typeof parsedRecord.text === "string" ? parsedRecord.text : "";
892
950
  const validToolCalls = parsedRecord.tool_calls
@@ -922,18 +980,115 @@ ${boundedMainMemory.trim()}`
922
980
  } catch {
923
981
  // Not JSON, treat as regular assistant text.
924
982
  }
925
- return [{ role: "assistant" as const, content: msg.content }];
983
+ return [{ role: "assistant" as const, content: assistantText }];
926
984
  }
927
985
 
928
- if (msg.role === "user" || msg.role === "system") {
986
+ if (msg.role === "system") {
929
987
  return [{
930
- role: msg.role,
931
- content: msg.content,
988
+ role: "system" as const,
989
+ content: typeof msg.content === "string" ? msg.content : getTextContent(msg),
932
990
  }];
933
991
  }
934
992
 
993
+ if (msg.role === "user") {
994
+ if (typeof msg.content === "string") {
995
+ return [{ role: "user" as const, content: msg.content }];
996
+ }
997
+ // Convert ContentPart[] to Vercel AI SDK UserContent.
998
+ // Only specific media types can be sent as binary attachments —
999
+ // everything else is inlined as text or skipped gracefully.
1000
+ const MODEL_IMAGE_TYPES = new Set([
1001
+ "image/jpeg", "image/png", "image/gif", "image/webp",
1002
+ ]);
1003
+ const MODEL_FILE_TYPES = new Set([
1004
+ "application/pdf",
1005
+ ]);
1006
+ const isTextBasedMime = (mt: string): boolean =>
1007
+ mt.startsWith("text/") ||
1008
+ mt === "application/json" ||
1009
+ mt === "application/xml" ||
1010
+ mt === "application/x-yaml" ||
1011
+ mt.endsWith("+json") ||
1012
+ mt.endsWith("+xml");
1013
+
1014
+ const userContent = await Promise.all(
1015
+ msg.content.map(async (part) => {
1016
+ if (part.type === "text") {
1017
+ return { type: "text" as const, text: part.text };
1018
+ }
1019
+
1020
+ const isSupportedImage = MODEL_IMAGE_TYPES.has(part.mediaType);
1021
+ const isSupportedFile = MODEL_FILE_TYPES.has(part.mediaType);
1022
+
1023
+ // Text-based files: inline the content so the model can read it
1024
+ if (!isSupportedImage && !isSupportedFile && isTextBasedMime(part.mediaType)) {
1025
+ let textContent: string;
1026
+ try {
1027
+ if (part.data.startsWith(PONCHO_UPLOAD_SCHEME) && this.uploadStore) {
1028
+ const buf = await this.uploadStore.get(part.data);
1029
+ textContent = buf.toString("utf8");
1030
+ } else if (part.data.startsWith("https://") || part.data.startsWith("http://")) {
1031
+ const resp = await fetch(part.data);
1032
+ textContent = await resp.text();
1033
+ } else {
1034
+ textContent = Buffer.from(part.data, "base64").toString("utf8");
1035
+ }
1036
+ } catch {
1037
+ textContent = "(could not read file)";
1038
+ }
1039
+ const label = part.filename ?? part.mediaType;
1040
+ return { type: "text" as const, text: `[File: ${label}]\n${textContent}` };
1041
+ }
1042
+
1043
+ // Unsupported binary formats (e.g. AVIF, HEIC, MP4): skip with a note
1044
+ if (!isSupportedImage && !isSupportedFile) {
1045
+ const label = part.filename ?? part.mediaType;
1046
+ return {
1047
+ type: "text" as const,
1048
+ text: `[Attached file: ${label} (${part.mediaType}) — this format is not supported by the model and was skipped]`,
1049
+ };
1050
+ }
1051
+
1052
+ // Always resolve to base64 so the model doesn't need to
1053
+ // fetch URLs itself (which fails for private blob stores).
1054
+ let resolvedData: string;
1055
+ if (part.data.startsWith(PONCHO_UPLOAD_SCHEME) && this.uploadStore) {
1056
+ const buf = await this.uploadStore.get(part.data);
1057
+ resolvedData = buf.toString("base64");
1058
+ } else if (part.data.startsWith("https://") || part.data.startsWith("http://")) {
1059
+ if (this.uploadStore) {
1060
+ const buf = await this.uploadStore.get(part.data);
1061
+ resolvedData = buf.toString("base64");
1062
+ } else {
1063
+ const resp = await fetch(part.data);
1064
+ resolvedData = Buffer.from(await resp.arrayBuffer()).toString("base64");
1065
+ }
1066
+ } else {
1067
+ resolvedData = part.data;
1068
+ }
1069
+ if (isSupportedImage) {
1070
+ return {
1071
+ type: "image" as const,
1072
+ image: resolvedData,
1073
+ mediaType: part.mediaType,
1074
+ };
1075
+ }
1076
+ return {
1077
+ type: "file" as const,
1078
+ data: resolvedData,
1079
+ mediaType: part.mediaType,
1080
+ filename: part.filename,
1081
+ };
1082
+ }),
1083
+ );
1084
+ return [{ role: "user" as const, content: userContent }];
1085
+ }
1086
+
935
1087
  return [];
936
- });
1088
+ };
1089
+ const coreMessages: ModelMessage[] = (
1090
+ await Promise.all(trimMessageWindow(messages).map(convertMessage))
1091
+ ).flat();
937
1092
 
938
1093
  const modelName = agent.frontmatter.model?.name ?? "claude-opus-4-5";
939
1094
  const temperature = agent.frontmatter.model?.temperature ?? 0.2;
@@ -1319,14 +1474,27 @@ ${boundedMainMemory.trim()}`
1319
1474
  }
1320
1475
  }
1321
1476
 
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
- };
1477
+ if (softDeadlineMs > 0) {
1478
+ const result: RunResult = {
1479
+ status: "completed",
1480
+ response: responseText,
1481
+ steps: maxSteps,
1482
+ tokens: { input: totalInputTokens, output: totalOutputTokens, cached: 0 },
1483
+ duration: now() - start,
1484
+ continuation: true,
1485
+ maxSteps,
1486
+ };
1487
+ yield pushEvent({ type: "run:completed", runId, result });
1488
+ } else {
1489
+ yield pushEvent({
1490
+ type: "run:error",
1491
+ runId,
1492
+ error: {
1493
+ code: "MAX_STEPS_EXCEEDED",
1494
+ message: `Run reached maximum of ${maxSteps} steps`,
1495
+ },
1496
+ });
1497
+ }
1330
1498
  }
1331
1499
 
1332
1500
  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";