Skip to content

feat(evals): Run workflow evals on the Claude Agent SDK - #1238

Merged
RobertCrupa merged 4 commits into
masterfrom
feat/claude-agent-sdk-evals
Aug 18, 2026
Merged

feat(evals): Run workflow evals on the Claude Agent SDK#1238
RobertCrupa merged 4 commits into
masterfrom
feat/claude-agent-sdk-evals

Conversation

@RobertCrupa

@RobertCrupa RobertCrupa commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Stacked on #1224. First of two: this one makes the agent under test the real Claude Code harness. #1239 traces its conversation to Langfuse.

Why

We want to simulate a user but having the same environment as them during evals. This means using the same harness, system prompt and tools.

What

The hand-rolled OpenRouter loop and MCP client are gone. Each case now runs Claude Code headlessly through the Claude Agent SDK, with the claude_code system-prompt and tool presets, driving its own freshly spawned Apify MCP server.

The judge, dataset, scores, and run gate are unchanged

Note for reviewers

The evaluator is run with allowDangerouslySkipPermissions (headless, never prompts; the SDK requires both) so run it with the MCP tools only to stop it from executing bash

Verification

  • --id search-google-maps: 1/1 passed, and a direct probe confirmed the agent calls the MCP search-actors tool (thinking, tool call, answer).
  • report-problem-on-tool-error: injection confirmed. The agent receives the refusal with the real report-problem nudge, retries call-actor, then explains to the user instead of calling report-problem. The case fails, and that is a genuine eval signal rather than a harness bug.
  • type-check, lint, test:unit (1280 pass / 1 skip), format, check:agents all green.

@RobertCrupa
RobertCrupa force-pushed the feat/claude-agent-sdk-evals branch from 45c5693 to 581d7e9 Compare August 12, 2026 10:01
@RobertCrupa RobertCrupa changed the title feat(evals): Run workflow evals on Claude Code and trace them feat(evals): Run workflow evals on the Claude Agent SDK Aug 12, 2026
@RobertCrupa
RobertCrupa force-pushed the feat/claude-agent-sdk-evals branch from 581d7e9 to 1a13562 Compare August 12, 2026 10:32
Base automatically changed from feat/langfuse-workflow-experiments to master August 12, 2026 11:41
@RobertCrupa
RobertCrupa requested review from MQ37 and jirispilka August 12, 2026 12:34
@RobertCrupa
RobertCrupa force-pushed the feat/claude-agent-sdk-evals branch from 1a13562 to b145326 Compare August 12, 2026 12:46
@github-actions github-actions Bot added t-ai Issues owned by the AI team. tested Temporary label used only programatically for some analytics. labels Aug 12, 2026

@MQ37 MQ37 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. One issue worth addressing (before or after merge):

sdk_conversation_adapter.ts only handles the error_max_turns result subtype from the SDK. If the run instead fails mid-conversation for a different reason (e.g. error_during_execution — an Anthropic API error, context overflow, etc.), that failure is silently dropped. The partial conversation gets sent to the LLM judge exactly like a normal run, so the judge scores it PASS/FAIL as if the agent just answered badly — it has no way to know the run was actually cut short by an error. That failure then shows up in the run's pass/fail results looking like a real eval failure, with no error logged anywhere.

Verified this in a sandbox: an API-error run and a max-turns run produce identical conversation.turns, and workflow_judge.ts never even reads completed/hitMaxTurns.

This contradicts the harness's own stated design: harness errors should fail the run, not be scored as a failing eval. Recommend throwing on any non-success, non-max-turns result subtype instead.

@RobertCrupa

RobertCrupa commented Aug 14, 2026

Copy link
Copy Markdown
Contributor Author

@MQ37 Thank you! good catch it is now fixed a442572

@RobertCrupa
RobertCrupa force-pushed the feat/claude-agent-sdk-evals branch from 1e048c8 to a442572 Compare August 14, 2026 09:56

@jirispilka jirispilka left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is mostly good but, I believe, you've chosen a wrong transformation, which makes the code more complicated than it should be.

I'm thinking aloud here, we plan to move these evals to Actor so I don't think it is worth to change it :). I hope that we will remove this sooner than later. I also hope that Arto is not going to build on this. So please perhaps just check what it would take to re do, if u agree.

My notes below:

I would rather change judge than introduce conversation adapter. You need to rebuild ConversationHistory .

formatConversationForJudge reads four things: userPrompt, turns[].toolCalls[].name/.arguments, and turns[].finalResponse. langfuse_experiment.ts reads conversation.totalTokens. Nothing else in the repo reads a conversation. The turn grouping the judge needs is one blank line, so a formatter over SDKMessage[] produces the same prompt with no intermediate object.

Pros

  • No conversion on the judge path. The judge reads the SDK's own types. And these types are pretty much a standard for other provides too!
  • Five types deleted: ConversationHistory, ConversationTurn, McpToolCall, McpTool, McpToolResult.
  • The 4-clause terminal-turn guard disappears. "Is the final answer already in the stream" becomes one condition.
  • What is left of the adapter drops from 223 lines to 101, and it stops being an adapter: usage totals, the transcript, and the error-subtype gate, which the stream genuinely does not give you for free. Across evals/workflows, 732 lines becomes 609.
  • The judge prompt is unchanged, so this is a refactor and not a behavior change.

Cons

  • It touches the judge and its test, which this PR set out not to touch.
  • #1239 needs rework, more than a rebase. It picks up toolInvocations, metrics, and claudeCodeVersion along with the receivedAt threading, and langfuse_observations.ts reads the final answer from the result message instead of turns.at(-1)?.finalResponse.

Port the Claude Agent SDK harness from braintrust-evals (cd733eef) onto the
Langfuse runner, dropping that commit's Braintrust migration. The agent under
test is now the real Claude Code harness driving its own Apify MCP server, so a
run exercises the server the way a Claude Code user does. The judge, dataset,
scores, and run gate are unchanged.

- The SDK spawns the MCP server per item (fresh, isolated); a per-case
  AbortController tears it down. systemPrompt and tools use the claude_code
  presets, permissionMode is bypassPermissions, settingSources is empty (no
  repo config inherited), cwd is a temp dir, tool timeout maps to the
  per-server timeout. --mcp-tools-only drops the built-in tools.
- Register the server with alwaysLoad: true. Deferred behind tool search (the
  default with built-in tools on) the agent never sees the Apify tools and
  answers from memory or Bash instead.
- failTools injection moves to a PreToolUse deny, the hook that survives
  bypassPermissions. Verified: the agent receives the synthetic INTERNAL_ERROR
  with the real report-problem nudge.
- sdk_conversation_adapter.ts folds the SDK message stream back into
  ConversationHistory, so the judge view matches earlier experiments. Cached
  prompt tokens count toward total_tokens; the API reports them separately and
  a cached run would otherwise look nearly free.
- --agent-model now takes Anthropic model IDs, default claude-haiku-4-5.
  ANTHROPIC_API_KEY is optional: without it the SDK uses the Claude Code CLI
  credentials. agentSdkVersion is recorded in the run metadata.
- Strip a forwarded bare `--` before yargs. `pnpm run evals:workflow -- --id x`
  passed it through to tsx, where yargs read it as end-of-options and ignored
  every flag, silently running all 30 cases instead of one.
- Delete conversation_executor.ts, mcp_client.ts, and the executor's test.
The adapter only recognized error_max_turns, so a run aborted mid-conversation
(API error, context overflow, budget) was handed to the judge as a normal
partial conversation and scored as a failing eval with no error logged. Throw
instead, so it fails the run like other harness errors.
The adapter populated seven fields nothing downstream read: turnNumber,
toolResults, completed, hitMaxTurns, totalTurns, promptTokens and
completionTokens. The judge reads userPrompt, toolCalls and finalResponse;
the experiment reads totalTokens. Tool results and the token split are
already on ToolInvocation and ConversationMetrics.

Dropping the per-turn toolResults removes the turnIndex threading from
PendingToolUse, so a tool result no longer has to find its turn.

The max-turns test now asserts what the judge sees (no final answer
appended) instead of the dropped flags. The judge prompt is unchanged.
@RobertCrupa
RobertCrupa force-pushed the feat/claude-agent-sdk-evals branch from 209eb72 to a52b707 Compare August 18, 2026 09:43
@RobertCrupa

Copy link
Copy Markdown
Contributor Author

@jirispilka Thank you for the comments. I have simplified the data structure.

When we do the Actor implementation, we will change the judge also.

@RobertCrupa
RobertCrupa merged commit de46c3e into master Aug 18, 2026
13 checks passed
@RobertCrupa
RobertCrupa deleted the feat/claude-agent-sdk-evals branch August 18, 2026 09:44
RobertCrupa added a commit that referenced this pull request Aug 20, 2026
Stacked on #1238. Second of two: #1238 makes the agent under test the
real Claude Code harness, this one puts its conversation on the Langfuse
trace.

## What

- `langfuse_observations.ts` builds the tree from the adapted SDK stream
and emits it.
- Tool spans are timed from when the SDK delivered the call and its
result.
- `llm_client.ts` wraps each call in a generation, which is what puts
the judge call, its verdict, and its tokens on the trace.
- A failed tool call is raised to `ERROR` level with the payload as the
status, and a run that never reached a final answer is flagged
`WARNING`, so both are findable in the UI.
- The tree is emitted before the judge call, so a failing judge still
leaves the conversation on the trace to debug. A crashed agent run
leaves no spans.
- Adds `@opentelemetry/api`, the peer dependency of `@langfuse/tracing`,
for the `SpanContext` type.

## Verification

- `--id search-google-maps`: 1/1 passed, the five observations land with
the right nesting, and `GET /api/public/v2/metrics` reports 71.5k tokens
and $0.039 on the agent generation plus 798 tokens on the judge one.
- `type-check`, `lint`, `test:unit` (1289 pass / 1 skip), `format`,
`check:agents` all green.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

t-ai Issues owned by the AI team. tested Temporary label used only programatically for some analytics.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants