-
-
Notifications
You must be signed in to change notification settings - Fork 393
feat(workflow): observe Codex and Claude agent runs #153
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -19,11 +19,13 @@ import { | |
| ProviderSchemaUnsupportedError, | ||
| type LocalAgentRunInput, | ||
| type LocalAgentRunResult, | ||
| type LocalAgentObserver, | ||
| type LocalAgentUsageSnapshot, | ||
| } from "./local-agent-runtime.js"; | ||
|
|
||
| export interface LocalAgentAdapter { | ||
| readonly provider: LocalAgentProvider; | ||
| run(input: LocalAgentRunInput): Promise<LocalAgentRunResult>; | ||
| run(input: LocalAgentRunInput, observer?: LocalAgentObserver): Promise<LocalAgentRunResult>; | ||
| } | ||
|
|
||
| const ACP_COMMANDS: Record<"cursor" | "copilot", [string, ...string[]]> = { | ||
|
|
@@ -35,18 +37,20 @@ const PI_AGENT_TIMEOUT_MS = 120_000; | |
| export async function runLocalAgentProvider( | ||
| provider: LocalAgentProvider, | ||
| input: LocalAgentRunInput, | ||
| observer?: LocalAgentObserver, | ||
| ): Promise<LocalAgentRunResult> { | ||
| const result = await runLocalAgentProviderResult(provider, input); | ||
| const result = await runLocalAgentProviderResult(provider, input, observer); | ||
| if (result.isErr()) throw result.error; | ||
| return result.value; | ||
| } | ||
|
|
||
| export async function runLocalAgentProviderResult( | ||
| provider: LocalAgentProvider, | ||
| input: LocalAgentRunInput, | ||
| observer?: LocalAgentObserver, | ||
| ): Promise<BetterResult<LocalAgentRunResult, AgentProviderError>> { | ||
| return Result.tryPromise({ | ||
| try: () => createLocalAgentAdapter(provider).run(input), | ||
| try: () => createLocalAgentAdapter(provider).run(input, observer), | ||
| catch: (cause) => classifyAgentProviderError(provider, cause), | ||
| }); | ||
| } | ||
|
|
@@ -70,16 +74,16 @@ export function createLocalAgentAdapter(provider: LocalAgentProvider): LocalAgen | |
| class CodexLocalAgentAdapter implements LocalAgentAdapter { | ||
| readonly provider = "codex" as const; | ||
|
|
||
| async run(input: LocalAgentRunInput): Promise<LocalAgentRunResult> { | ||
| async run(input: LocalAgentRunInput, observer?: LocalAgentObserver): Promise<LocalAgentRunResult> { | ||
| const runtime = await createCodexSdkLocalAgentRuntime(); | ||
| return runtime.run(input); | ||
| return runtime.run(input, observer); | ||
| } | ||
| } | ||
|
|
||
| class ClaudeLocalAgentAdapter implements LocalAgentAdapter { | ||
| readonly provider = "claude" as const; | ||
|
|
||
| async run(input: LocalAgentRunInput): Promise<LocalAgentRunResult> { | ||
| async run(input: LocalAgentRunInput, observer?: LocalAgentObserver): Promise<LocalAgentRunResult> { | ||
| const { query } = await import("@anthropic-ai/claude-agent-sdk"); | ||
| const claudeExecutable = process.env.CLAUDE_COMMAND ?? resolveExecutable("claude"); | ||
| try { | ||
|
|
@@ -107,7 +111,11 @@ class ClaudeLocalAgentAdapter implements LocalAgentAdapter { | |
| for await (const message of messages) { | ||
| items.push(message); | ||
| const record = message as Record<string, unknown>; | ||
| if (typeof record.session_id === "string") providerSessionId = record.session_id; | ||
| if (typeof record.session_id === "string") { | ||
| providerSessionId = record.session_id; | ||
| observer?.onSession?.(record.session_id); | ||
| } | ||
| notifyClaudeActivity(record, observer); | ||
| if (record.type !== "result") continue; | ||
| const resultError = claudeResultError(record); | ||
| if (resultError) throw new Error(resultError); | ||
|
|
@@ -116,14 +124,21 @@ class ClaudeLocalAgentAdapter implements LocalAgentAdapter { | |
| finalResponse = extracted.finalResponse; | ||
| structured = extracted.structured; | ||
| } | ||
| const usage = claudeUsage(record.usage, "final"); | ||
| if (usage) observer?.onUsage?.(usage); | ||
| } | ||
|
|
||
| finalResponse = requireFinalResponse("Claude", finalResponse); | ||
| const usage = [...items] | ||
| .reverse() | ||
| .map((item) => claudeUsage((item as Record<string, unknown>).usage, "final")) | ||
| .find((snapshot) => snapshot !== undefined); | ||
| return { | ||
| provider: this.provider, | ||
| providerSessionId, | ||
| finalResponse, | ||
| items, | ||
| ...(usage ? { usage } : {}), | ||
| ...(structured !== undefined ? { structured } : {}), | ||
| }; | ||
| } catch (error) { | ||
|
|
@@ -135,6 +150,58 @@ class ClaudeLocalAgentAdapter implements LocalAgentAdapter { | |
| } | ||
| } | ||
|
|
||
| function notifyClaudeActivity(record: Record<string, unknown>, observer?: LocalAgentObserver): void { | ||
| if (record.type === "tool_progress" && typeof record.tool_name === "string") { | ||
| observer?.onActivity?.({ kind: "tool", status: "running", label: record.tool_name }); | ||
| return; | ||
| } | ||
| if (record.type === "tool_use_summary" && typeof record.summary === "string") { | ||
| observer?.onActivity?.({ kind: "tool", status: "completed", label: record.summary }); | ||
| return; | ||
| } | ||
| if (record.type !== "assistant") return; | ||
| const message = record.message as { content?: unknown[] } | undefined; | ||
| for (const block of message?.content ?? []) { | ||
| const content = block as Record<string, unknown>; | ||
| if (content.type !== "tool_use" || typeof content.name !== "string") continue; | ||
| observer?.onActivity?.({ | ||
| kind: content.name === "Bash" ? "command" : content.name === "Write" || content.name === "Edit" ? "file" : "tool", | ||
| status: "running", | ||
| label: content.name, | ||
| detail: claudeToolDetail(content.input), | ||
| }); | ||
| } | ||
| } | ||
|
|
||
| function claudeToolDetail(input: unknown): string | undefined { | ||
| if (!input || typeof input !== "object") return undefined; | ||
| const record = input as Record<string, unknown>; | ||
| for (const key of ["command", "file_path", "path", "query"]) { | ||
| if (typeof record[key] === "string") return record[key]; | ||
| } | ||
| return undefined; | ||
| } | ||
|
|
||
| function claudeUsage(value: unknown, state: "partial" | "final"): LocalAgentUsageSnapshot | undefined { | ||
| if (!value || typeof value !== "object") return undefined; | ||
| const usage = value as Record<string, unknown>; | ||
| const inputTokens = nonNegativeInteger(usage.input_tokens); | ||
| const outputTokens = nonNegativeInteger(usage.output_tokens); | ||
| if (inputTokens === undefined && outputTokens === undefined) return undefined; | ||
| return { | ||
| inputTokens, | ||
| cachedInputTokens: nonNegativeInteger(usage.cache_read_input_tokens), | ||
| cacheCreationInputTokens: nonNegativeInteger(usage.cache_creation_input_tokens), | ||
| outputTokens, | ||
| totalTokens: (inputTokens ?? 0) + (outputTokens ?? 0), | ||
| state, | ||
| }; | ||
|
Comment on lines
+188
to
+198
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
printf 'Files matching local-agent-adapters.ts:\n'
fd -a 'local-agent-adapters\.ts$' . || true
printf '\nRelevant file excerpt:\n'
file="$(fd 'local-agent-adapters\.ts$' . | head -n 1 || true)"
if [ -n "${file:-}" ]; then
wc -l "$file"
sed -n '150,220p' "$file" | nl -ba -v150
fi
printf '\nSearch usage of function/totalTokens/cached tokens:\n'
rg -n "totalTokens|cache_read_input_tokens|cache_creation_input_tokens|inputTokens|cachedInputTokens|cacheCreationInputTokens|nonNegativeInteger" .Repository: Waishnav/devspace Length of output: 341 🌐 Web query:
💡 Result: Claude prompt caching allows you to store and reuse portions of your prompt (the "prefix") across multiple API requests, significantly reducing latency and cost [1][2]. The total input tokens for a request using prompt caching are calculated as the sum of three distinct categories provided in the usage statistics [3][4]: total_input_tokens = cache_read_input_tokens + cache_creation_input_tokens + input_tokens Here is the breakdown of these terms: 1. cache_read_input_tokens: The number of tokens retrieved from an existing cache entry [3][5]. These are billed at 10% of the standard input token price [3][1]. 2. cache_creation_input_tokens: The number of tokens being written to a new cache entry (or a refresh) [3][5]. These are billed at a premium (1.25x the base input price for a 5-minute TTL, or 2x for a 1-hour TTL) [1][6]. 3. input_tokens: The number of "uncached" tokens, representing only the content that appears after the last cache breakpoint in your request [3][4]. These are billed at the standard input token rate [3][1]. Key operational details: - Prefix Matching: Caching is based on exact "prefix matching" [7][8]. The API caches content from the start of the request up to designated cache breakpoints [5][7]. If any part of the prompt before a breakpoint changes, the cache is invalidated for that breakpoint and all subsequent ones [9][4][8]. - Diagnostics: If you observe cache_read_input_tokens dropping to zero unexpectedly, you can use cache diagnostics to determine if the prefix has changed or if the cache entry has expired [9]. - Ordering Matters: Because the cache is a byte-for-byte prefix match, the order of content—such as tool definitions, system prompts, and message history—is critical [7][8]. Organizing your prompt to maximize stable, shared prefixes is the most effective way to utilize this feature [7]. Citations:
🏁 Script executed: #!/bin/bash
set -euo pipefail
file="$(fd 'local-agent-adapters\.ts$' . | head -n 1)"
printf 'File: %s\n' "$file"
printf '\nLines 180-205:\n'
awk 'NR>=180 && NR<=205 {printf "%6d %s\n", NR, $0}' "$file"
printf '\nLines 220-290:\n'
awk 'NR>=220 && NR<=290 {printf "%6d %s\n", NR, $0}' "$file"
printf '\nSearch relevant usages:\n'
rg -n "totalTokens|cache_read_input_tokens|cache_creation_input_tokens|inputTokens|cachedInputTokens|cacheCreationInputTokens|nonNegativeInteger|persist|observer" src || trueRepository: Waishnav/devspace Length of output: 16327 Include Claude cache tokens in
🧰 Tools🪛 ast-grep (0.45.0)[warning] Importing child_process exposes a command-execution surface; ensure any command/argument built from input is validated, and prefer execFile/spawn with an argument array over exec. (detect-child-process-typescript) 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| function nonNegativeInteger(value: unknown): number | undefined { | ||
| return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : undefined; | ||
| } | ||
|
|
||
| /** Build Claude SDK outputFormat when a JSON Schema is requested. */ | ||
| export function claudeOutputFormatOptions( | ||
| schema: JsonSchema | undefined, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Emit partial Claude usage updates.
Lines 127-128 call
onUsageonly for aresultrecord. Line 119 skips this block for every non-result record. The observer therefore never receives astate: "partial"snapshot.Parse usage on each received record. Emit
partialfor non-result records. Keep theresultrecord asfinal. Add a test with an intermediate usage record.Proposed fix
📝 Committable suggestion
🧰 Tools
🪛 ast-grep (0.45.0)
[warning] Importing child_process exposes a command-execution surface; ensure any command/argument built from input is validated, and prefer execFile/spawn with an argument array over exec.
Context: import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from "node:child_process";
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').
(detect-child-process-typescript)
🤖 Prompt for AI Agents