@inflexa-ai/harness 0.36.0 → 0.36.1

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.
@@ -3,6 +3,10 @@
3
3
  * `runAgent`, and `execute_tool` around each tool call that it dispatches. The AI
4
4
  * SDK traces the model calls (`providers/ai-sdk.ts`). It never sees the loop or
5
5
  * the tools, because the harness runs both itself.
6
+ *
7
+ * An `execute_tool` span is ERROR when our code fails the call. A failure that a
8
+ * tool returns as data, for example a sandbox command that exits non-zero, is
9
+ * normal iteration of the agent, and its span stays OK.
6
10
  */
7
11
  import type { ToolCallPart } from "ai";
8
12
  import type { AgentSession } from "../auth/types.js";
@@ -17,4 +21,14 @@ interface AgentRun {
17
21
  export declare function traceAgentRun<T extends AgentRun>(agentName: string, session: AgentSession, run: () => Promise<T>): Promise<T>;
18
22
  /** Run one tool call under an `execute_tool` span that records how the call ended. */
19
23
  export declare function traceToolCall<T>(call: Pick<ToolCallPart, "toolName" | "toolCallId">, run: () => Promise<T>, outcomeOf: (value: T) => ToolOutcome): Promise<T>;
24
+ /**
25
+ * Record an exception that a tool threw on the active `execute_tool` span.
26
+ * `error.type` is the code of the error when it has one, else its name.
27
+ */
28
+ export declare function recordToolException(err: Error): void;
29
+ /**
30
+ * Label a tool failure that is not an exception on the active `execute_tool`
31
+ * span. `message` is text that our tool wrote, never text from the model.
32
+ */
33
+ export declare function labelToolFailure(type: string, message?: string): void;
20
34
  export {};
@@ -3,6 +3,10 @@
3
3
  * `runAgent`, and `execute_tool` around each tool call that it dispatches. The AI
4
4
  * SDK traces the model calls (`providers/ai-sdk.ts`). It never sees the loop or
5
5
  * the tools, because the harness runs both itself.
6
+ *
7
+ * An `execute_tool` span is ERROR when our code fails the call. A failure that a
8
+ * tool returns as data, for example a sandbox command that exits non-zero, is
9
+ * normal iteration of the agent, and its span stays OK.
6
10
  */
7
11
  import { SpanStatusCode, trace } from "@opentelemetry/api";
8
12
  /** Run one agent loop under an `invoke_agent` span. */
@@ -26,6 +30,37 @@ export function traceToolCall(call, run, outcomeOf) {
26
30
  return value;
27
31
  });
28
32
  }
33
+ /** The cap of `inflexa.tool.error`, in characters. */
34
+ const TOOL_ERROR_MAX_CHARS = 1_000;
35
+ /**
36
+ * Record an exception that a tool threw on the active `execute_tool` span.
37
+ * `error.type` is the code of the error when it has one, else its name.
38
+ */
39
+ export function recordToolException(err) {
40
+ const span = trace.getActiveSpan();
41
+ if (span === undefined)
42
+ return;
43
+ span.recordException(err);
44
+ span.setAttribute("error.type", errorTypeOf(err));
45
+ }
46
+ /**
47
+ * Label a tool failure that is not an exception on the active `execute_tool`
48
+ * span. `message` is text that our tool wrote, never text from the model.
49
+ */
50
+ export function labelToolFailure(type, message) {
51
+ const span = trace.getActiveSpan();
52
+ if (span === undefined)
53
+ return;
54
+ span.setAttribute("error.type", type);
55
+ if (message !== undefined)
56
+ span.setAttribute("inflexa.tool.error", message.slice(0, TOOL_ERROR_MAX_CHARS));
57
+ }
58
+ function errorTypeOf(err) {
59
+ const code = "code" in err ? err.code : undefined;
60
+ if ((typeof code === "string" && code !== "") || typeof code === "number")
61
+ return String(code);
62
+ return err.name === "" ? "_OTHER" : err.name;
63
+ }
29
64
  function withSpan(name, attributes, run) {
30
65
  return trace.getTracer("@inflexa-ai/harness").startActiveSpan(name, { attributes }, async (span) => {
31
66
  try {
@@ -4,6 +4,7 @@ import { createNoopUsageRecorder } from "../billing/noop-usage-recorder.js";
4
4
  import { stripNulCharacters } from "../input-sanitization.js";
5
5
  import { createNoopLogger } from "../lib/console-logger.js";
6
6
  import { ATTR_INFLEXA_TOOL_USE_ID, stableSpan } from "../lib/otel-spans.js";
7
+ import { ResultError } from "../lib/result.js";
7
8
  import { hintForZodIssue, repairToolInput } from "../lib/zod-issues.js";
8
9
  import { markInterruptedMessage, syntheticUserMessage } from "../memory/ai-sdk-message-storage.js";
9
10
  import { stripUnansweredToolCalls } from "../memory/tool-call-integrity.js";
@@ -13,7 +14,7 @@ import { DEFAULT_REASONING } from "../providers/reasoning.js";
13
14
  import { resultStep } from "./run-step.js";
14
15
  import { AskRejectedError, UnavailableAsk } from "../tools/approval/contract.js";
15
16
  import { isToolError, readToolResultImages } from "../tools/define-tool.js";
16
- import { traceAgentRun, traceToolCall } from "./genai-spans.js";
17
+ import { labelToolFailure, recordToolException, traceAgentRun, traceToolCall } from "./genai-spans.js";
17
18
  import { addChatUsage, hasReportedUsage, recordAgentRun } from "./metrics.js";
18
19
  import { computeDetail, computeResultDetail } from "./tool-detail.js";
19
20
  import { toolOutcomeForOutputType } from "./tool-outcome.js";
@@ -602,6 +603,7 @@ function readDispatchedStep(settled) {
602
603
  async function dispatchTool(tu, toolsById, ctx, isFatalLoopError, encoding) {
603
604
  const tool = toolsById.get(tu.toolName);
604
605
  if (tool === undefined) {
606
+ labelToolFailure("unknown_tool");
605
607
  return { result: errorResult(tu, `unknown tool: ${tu.toolName}`) };
606
608
  }
607
609
  const parsed = tool.inputSchema.safeParse(tu.input);
@@ -615,13 +617,17 @@ async function dispatchTool(tu, toolsById, ctx, isFatalLoopError, encoding) {
615
617
  const repaired = repairedInput === undefined ? undefined : tool.inputSchema.safeParse(repairedInput);
616
618
  if (repaired?.success === true)
617
619
  return execute(tu, tool, repaired.data, ctx, isFatalLoopError, encoding);
620
+ // The Zod text can quote the input, thus the span gets no message.
621
+ labelToolFailure("validation");
618
622
  return { result: errorResult(tu, `input validation failed: ${formatZodIssues(parsed.error, tu.input)}`) };
619
623
  }
620
624
  async function execute(tu, tool, input, ctx, isFatalLoopError, encoding) {
621
625
  try {
622
626
  const output = await tool.execute(input, ctx);
623
- if (output.isErr())
627
+ if (output.isErr()) {
628
+ markToolFailure(tu, tool, output.error, encoding.log);
624
629
  return { result: errorResult(tu, toolErrorContent(output.error)) };
630
+ }
625
631
  // The one place a result description runs: the ok value in hand, the
626
632
  // input already validated, and the guard inside the compute.
627
633
  const detail = computeResultDetail(tool, input, output.value, encoding.log);
@@ -633,9 +639,35 @@ async function execute(tu, tool, input, ctx, isFatalLoopError, encoding) {
633
639
  throw err;
634
640
  if (isAskRejected(err))
635
641
  return { result: deniedResult(tu, err.feedback) };
642
+ markToolFailure(tu, tool, err, encoding.log);
636
643
  return { result: errorResult(tu, toolErrorContent(err)) };
637
644
  }
638
645
  }
646
+ /**
647
+ * Label the span of a tool call that our tool code failed. A `ToolError` is a
648
+ * failure that the tool modeled, thus its own text is the account, with no
649
+ * exception and no log. Any other `Error` is a fault that the tool did not
650
+ * model: the span records the exception, and the log gets one warn.
651
+ */
652
+ function markToolFailure(tu, tool, failure, log) {
653
+ const toolError = modeledToolError(failure);
654
+ if (toolError !== undefined) {
655
+ labelToolFailure("tool_error", toolError.error);
656
+ }
657
+ else if (failure instanceof Error) {
658
+ recordToolException(failure);
659
+ log.warn("tool call threw", { tool: tool.id, toolCallId: tu.toolCallId, ...log.errorFields(failure) });
660
+ }
661
+ else {
662
+ labelToolFailure("_OTHER");
663
+ }
664
+ }
665
+ /** The `ToolError` of a failure: the value itself, or the value that `unwrapOrThrow` wrapped in a `ResultError`. */
666
+ function modeledToolError(failure) {
667
+ if (isToolError(failure))
668
+ return failure;
669
+ return failure instanceof ResultError && isToolError(failure.value) ? failure.value : undefined;
670
+ }
639
671
  /**
640
672
  * Normalize a tool's return value to plain JSON, stripping NUL from every string
641
673
  * it holds ({@link stripNulCharacters}). The reviver sees values, not keys — a
@@ -700,8 +732,9 @@ function successResult(toolCall, value, encoding) {
700
732
  // is the six-character escape \u0000 — legitimate JSON text, and no longer
701
733
  // distinguishable from an error that happens to quote that escape.
702
734
  function toolErrorContent(value) {
703
- if (isToolError(value)) {
704
- return JSON.stringify({ error: stripNulCharacters(value.error), retryable: value.retryable });
735
+ const toolError = modeledToolError(value);
736
+ if (toolError !== undefined) {
737
+ return JSON.stringify({ error: stripNulCharacters(toolError.error), retryable: toolError.retryable });
705
738
  }
706
739
  const { retryable } = classifyProviderError(value);
707
740
  const error = stripNulCharacters(value instanceof Error ? value.message : String(value));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inflexa-ai/harness",
3
- "version": "0.36.0",
3
+ "version": "0.36.1",
4
4
  "description": "Host-agnostic bioinformatics agent harness — hand-rolled agent loop, DBOS-durable workflows, sandboxed compute.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",