Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@tangle-network/traces",
"version": "0.11.5",
"description": "Point it at your coding-agent session traces (Claude Code, Codex, OpenCode, Gemini, Pi, ) and get failure-mode + efficiency findings. CLI + SDK over the @tangle-network/agent-eval analyst suite observe live sessions, run your own analysts, redact, and upload to the Tangle Intelligence Platform.",
"description": "Point it at your coding-agent session traces (Claude Code, Codex, OpenCode, Gemini, Pi, \u2026) and get failure-mode + efficiency findings. CLI + SDK over the @tangle-network/agent-eval analyst suite \u2014 observe live sessions, run your own analysts, redact, and upload to the Tangle Intelligence Platform.",
"type": "module",
"license": "MIT",
"repository": {
Expand Down Expand Up @@ -45,7 +45,7 @@
"README.md"
],
"engines": {
"node": ">=22"
"node": ">=22.13.0"
},
"scripts": {
"dev": "tsx src/cli.ts",
Expand All @@ -59,10 +59,10 @@
"prepublishOnly": "pnpm check:source && pnpm build && pnpm check:package"
},
"dependencies": {
"@tangle-network/agent-eval": "0.143.0",
"@tangle-network/agent-runtime": "0.126.0",
"@tangle-network/agent-eval": "0.145.3",
"@tangle-network/agent-runtime": "0.133.2",
"@tangle-network/agent-trace-contract": "^1.0.2",
"@tangle-network/sandbox": "0.17.2"
"@tangle-network/sandbox": "0.21.1"
},
"devDependencies": {
"@types/node": "^22.0.0",
Expand Down
95 changes: 48 additions & 47 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

111 changes: 111 additions & 0 deletions src/analyst-model-call.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import {
callLlm,
costReceiptFromLlm,
costReceiptFromLlmError,
LlmCallError,
type LlmCallRequest,
} from '@tangle-network/agent-eval'
import type { createDspyRlmTraceEngine } from '@tangle-network/agent-eval/analyst'

/**
* The engine's own model-call seam. Derived from the factory rather than
* imported, because agent-eval does not export the contract type by name and a
* hand-written copy would silently drift from the version installed here.
*/
type ExternalOptimizerModelCall = NonNullable<Parameters<typeof createDspyRlmTraceEngine>[0]['call']>

/**
* How much of a provider error body is retained as execution evidence. Enough
* to name the cause, bounded because the body is attacker-influenced and the
* proxy holds the whole execution record in memory.
*/
export const PROVIDER_ERROR_BODY_LIMIT = 2_000

/**
* The CLI's owned execution path for one admitted analyst model call.
*
* agent-eval 0.144.0 stopped accepting provider credentials: the caller owns
* execution and returns a typed result plus a cost receipt for every admitted
* call. This path is one OpenAI-compatible HTTP call through agent-eval's own
* `callLlm`.
*
* The callback always resolves. Rejecting loses the execution record and fails
* the whole optimizer attempt.
*/
export function createAnalystModelCall(opts: {
apiKey: string
baseUrl: string
}): ExternalOptimizerModelCall {
const { apiKey, baseUrl } = opts
return async ({ request, callId, signal }) => {
try {
const req = structuredClone(request) as LlmCallRequest
// callId is the ledger's stable identity for this one paid call, so it
// is the provider idempotency key: callLlm retries transient failures,
// and without it a lost-but-billed response is charged twice.
const response = await callLlm(req, {
apiKey,
baseUrl,
signal,
idempotencyKey: callId,
})
return {
succeeded: true,
response,
receipt: costReceiptFromLlm(response),
execution: {
callId,
baseUrl,
requestedModel: req.model,
servedModel: response.servedModel ?? null,
finishReason: response.finishReason ?? null,
durationMs: response.durationMs,
},
}
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error))
// The callback must resolve, so an abort cannot reach the proxy as a
// thrown AbortError and can never take its 504 branch. Naming the class
// in the failure text is what lets the bridge tell a cancelled call apart
// from a transient one it should retry.
//
// Only the caller's signal proves a cancellation. `callLlm` aborts an
// internal controller to enforce its own per-attempt timeout, so a plain
// provider timeout also surfaces as an `AbortError` while this signal
// stays clear. Reading the error name here would mark that timeout
// uncancellable and stop the bridge retrying a call it should retry.
const aborted = signal.aborted
const message = aborted ? `AbortError: ${err.message}` : err.message
// LlmCallError carries the provider's own reason (bad key, context
// length, unknown model). Without it the operator sees only the HTTP
// status. It stays in the execution evidence and out of the log line,
// because a gateway can echo request headers into an error body.
const detail =
err instanceof LlmCallError
? { status: err.status, body: err.body.slice(0, PROVIDER_ERROR_BODY_LIMIT) }
: {}
return {
succeeded: false,
error: message,
// costReceiptFromLlmError recovers the provider receipt when the
// response completed but violated the contract; otherwise usage and
// cost stay explicitly unknown.
receipt: costReceiptFromLlmError(err) ?? {
model: request.model,
inputTokens: 0,
outputTokens: 0,
costUnknown: true,
usageUnknown: true,
},
execution: {
callId,
baseUrl,
requestedModel: request.model,
aborted,
error: message,
...detail,
},
}
}
}
}
21 changes: 13 additions & 8 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ import {
import { fileRunContextSupervisorRunReader, isFileRunContextDir } from './supervisor-run-context.js'
import { resolveRunWatchTarget, watchRunTarget } from './run-watch.js'
import { createDspyRlmTraceEngine, type TraceAnalysisEngine } from '@tangle-network/agent-eval/analyst'
import { createAnalystModelCall } from './analyst-model-call.js'
import type { OtlpSpan } from './otlp.js'
import { serializeSpans, writeOtlpFile } from './otlp.js'
import type {
Expand Down Expand Up @@ -501,15 +502,19 @@ function buildAnalysisEngine(model: string, budgetUsd?: number): TraceAnalysisEn
(tangleKey ? TANGLE_ROUTER_BASE_URL : 'https://api.openai.com/v1')
const python = process.env.TRACES_PYTHON
return createDspyRlmTraceEngine({
apiKey,
baseUrl,
call: createAnalystModelCall({ apiKey, baseUrl }),
callRef: `traces-cli:${baseUrl}#${model}`,
recordExecution: (observation) => {
analystLog(
`[analyst] model call ${observation.sequence} ${observation.succeeded ? 'ok' : 'FAIL'} ${observation.model}`,
observation.succeeded ? undefined : { error: observation.error },
)
},
model,
// agent-eval 0.139.3's engine defaults are tuned below what real runs
// need. maxOutputTokens 4096 is under what current coding models emit for
// one findings array: glm-5.2 counts reasoning tokens in
// completion_tokens, the first oversized completion breaches its cost
// reservation, and the fail-closed ledger then refuses every later call
// in the run.
// Pinned, not defaulted: one findings array from a current coding model
// needs this cap, and glm-5.2 counts reasoning tokens in
// completion_tokens — a smaller cap breaches its cost reservation and the
// fail-closed ledger then refuses every later call in the run.
maxOutputTokens: 16_384,
// maxCostUsd defaults to $1 per analyst — a proxy-side ceiling separate
// from --budget. With the larger token cap the per-call reservation grows
Expand Down
Loading
Loading