shikiru-cli 0.2.0__py3-none-any.whl
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.
- shikiru/__init__.py +1 -0
- shikiru/__main__.py +8 -0
- shikiru/agents/__init__.py +3 -0
- shikiru/agents/contracts.py +590 -0
- shikiru/agents/registry.py +87 -0
- shikiru/agents/sdk/__init__.py +17 -0
- shikiru/agents/sdk/reset_time.py +137 -0
- shikiru/agents/sdk/turn.py +335 -0
- shikiru/agents/settings.py +151 -0
- shikiru/agents/testing/__init__.py +26 -0
- shikiru/agents/testing/contract_suite.py +653 -0
- shikiru/agents/testing/resume.py +15 -0
- shikiru/bootstrap.py +1277 -0
- shikiru/cli/__init__.py +1 -0
- shikiru/cli/app.py +770 -0
- shikiru/cli/context.py +63 -0
- shikiru/cli/render.py +1475 -0
- shikiru/cli/report.py +156 -0
- shikiru/cli/session_prompt.py +63 -0
- shikiru/cli/view.py +131 -0
- shikiru/clock.py +41 -0
- shikiru/config/__init__.py +8 -0
- shikiru/config/defaults.toml +131 -0
- shikiru/config/loader.py +178 -0
- shikiru/config/settings.py +392 -0
- shikiru/dashboard/__init__.py +3 -0
- shikiru/dashboard/actions_list.py +799 -0
- shikiru/dashboard/activity.py +150 -0
- shikiru/dashboard/app.py +1765 -0
- shikiru/dashboard/blocker_evidence.py +122 -0
- shikiru/dashboard/blocker_rows.py +84 -0
- shikiru/dashboard/briefing_view.py +871 -0
- shikiru/dashboard/contract.py +1086 -0
- shikiru/dashboard/covered.py +32 -0
- shikiru/dashboard/detail.py +1209 -0
- shikiru/dashboard/detail_fold.py +244 -0
- shikiru/dashboard/detail_hint.py +68 -0
- shikiru/dashboard/details_list_view.py +150 -0
- shikiru/dashboard/diagnostics.py +80 -0
- shikiru/dashboard/diagnostics_sections.py +186 -0
- shikiru/dashboard/evidence_table.py +123 -0
- shikiru/dashboard/extra_details.py +214 -0
- shikiru/dashboard/fault_guidance.py +216 -0
- shikiru/dashboard/fold_bodies.py +276 -0
- shikiru/dashboard/help_keys.py +166 -0
- shikiru/dashboard/help_screen.py +204 -0
- shikiru/dashboard/interaction.py +17 -0
- shikiru/dashboard/ladder_editor.py +544 -0
- shikiru/dashboard/live_settings.py +62 -0
- shikiru/dashboard/motion.py +368 -0
- shikiru/dashboard/orchestration_fields.py +201 -0
- shikiru/dashboard/palette.py +1606 -0
- shikiru/dashboard/palette_app.py +89 -0
- shikiru/dashboard/palette_drafts.py +33 -0
- shikiru/dashboard/resolution.py +573 -0
- shikiru/dashboard/resolution_view.py +302 -0
- shikiru/dashboard/row_list.py +256 -0
- shikiru/dashboard/section_rows.py +173 -0
- shikiru/dashboard/services.py +665 -0
- shikiru/dashboard/session_history.py +402 -0
- shikiru/dashboard/shikiru_init_ascii_art.txt +40 -0
- shikiru/dashboard/task_picker.py +358 -0
- shikiru/dashboard/tick.py +59 -0
- shikiru/dashboard/timeline_filter.py +129 -0
- shikiru/dashboard/timeline_section.py +385 -0
- shikiru/dashboard/usage_body.py +269 -0
- shikiru/dashboard/views.py +2314 -0
- shikiru/dashboard/wizard.py +1389 -0
- shikiru/domain/__init__.py +1 -0
- shikiru/domain/acceptance.py +130 -0
- shikiru/domain/activity.py +28 -0
- shikiru/domain/attempts.py +333 -0
- shikiru/domain/briefing.py +257 -0
- shikiru/domain/commands.py +219 -0
- shikiru/domain/decisions.py +231 -0
- shikiru/domain/docker.py +262 -0
- shikiru/domain/engine_uptime.py +87 -0
- shikiru/domain/escalation.py +194 -0
- shikiru/domain/escalation_evidence.py +186 -0
- shikiru/domain/escalation_panel.py +144 -0
- shikiru/domain/events.py +71 -0
- shikiru/domain/evidence_export.py +89 -0
- shikiru/domain/faults.py +86 -0
- shikiru/domain/feedback.py +180 -0
- shikiru/domain/feedback_rounds.py +172 -0
- shikiru/domain/fencing.py +31 -0
- shikiru/domain/ladder.py +102 -0
- shikiru/domain/ladder_text.py +44 -0
- shikiru/domain/levels.py +44 -0
- shikiru/domain/motion.py +199 -0
- shikiru/domain/packet_evidence.py +377 -0
- shikiru/domain/pushes.py +47 -0
- shikiru/domain/quota_blocks.py +44 -0
- shikiru/domain/quota_waits.py +219 -0
- shikiru/domain/recovery.py +194 -0
- shikiru/domain/reports.py +498 -0
- shikiru/domain/results.py +217 -0
- shikiru/domain/runs.py +85 -0
- shikiru/domain/scheduling.py +231 -0
- shikiru/domain/status_tasks.py +40 -0
- shikiru/domain/steps.py +325 -0
- shikiru/domain/tasks.py +174 -0
- shikiru/domain/timeline.py +208 -0
- shikiru/domain/timeline_phrases.py +271 -0
- shikiru/domain/versions.py +18 -0
- shikiru/domain/workflow.py +372 -0
- shikiru/engine/__init__.py +0 -0
- shikiru/engine/acceptance.py +90 -0
- shikiru/engine/agents.py +61 -0
- shikiru/engine/cosmetic_decorator.py +99 -0
- shikiru/engine/daemon.py +409 -0
- shikiru/engine/dependency.py +37 -0
- shikiru/engine/escalation_filing.py +110 -0
- shikiru/engine/escalations.py +401 -0
- shikiru/engine/events.py +285 -0
- shikiru/engine/evidence.py +81 -0
- shikiru/engine/fault_reporter.py +144 -0
- shikiru/engine/feedback.py +577 -0
- shikiru/engine/feedback_audit.py +80 -0
- shikiru/engine/feedback_intake.py +95 -0
- shikiru/engine/feedback_snapshot.py +148 -0
- shikiru/engine/flow.py +87 -0
- shikiru/engine/handlers/__init__.py +33 -0
- shikiru/engine/handlers/base.py +148 -0
- shikiru/engine/handlers/engine.py +24 -0
- shikiru/engine/handlers/escalations.py +174 -0
- shikiru/engine/handlers/operator.py +81 -0
- shikiru/engine/handlers/tasks.py +237 -0
- shikiru/engine/history.py +128 -0
- shikiru/engine/integration.py +1234 -0
- shikiru/engine/integration_turns.py +337 -0
- shikiru/engine/layout_guard.py +362 -0
- shikiru/engine/lifecycle.py +60 -0
- shikiru/engine/monitor.py +317 -0
- shikiru/engine/notification_delivery.py +124 -0
- shikiru/engine/notification_feed.py +57 -0
- shikiru/engine/notifier.py +91 -0
- shikiru/engine/pull_request_closing.py +117 -0
- shikiru/engine/push_service.py +177 -0
- shikiru/engine/quota_probe_threads.py +79 -0
- shikiru/engine/quota_reconciler.py +210 -0
- shikiru/engine/quota_schedule.py +24 -0
- shikiru/engine/quota_waits.py +102 -0
- shikiru/engine/reconcile.py +91 -0
- shikiru/engine/recovery.py +284 -0
- shikiru/engine/retention.py +102 -0
- shikiru/engine/root_preparation.py +86 -0
- shikiru/engine/scheduler.py +320 -0
- shikiru/engine/session_guard.py +46 -0
- shikiru/engine/sessions.py +95 -0
- shikiru/engine/signalled.py +75 -0
- shikiru/engine/step_runner.py +140 -0
- shikiru/engine/supervisor.py +101 -0
- shikiru/engine/takeover.py +223 -0
- shikiru/engine/task_facts.py +46 -0
- shikiru/engine/tick_guard.py +95 -0
- shikiru/engine/titles.py +297 -0
- shikiru/engine/transitions.py +79 -0
- shikiru/engine/turn_context.py +206 -0
- shikiru/engine/turn_execution.py +30 -0
- shikiru/engine/turn_loop.py +230 -0
- shikiru/engine/turn_processes.py +79 -0
- shikiru/engine/turns.py +457 -0
- shikiru/errors.py +144 -0
- shikiru/helper/__init__.py +3 -0
- shikiru/helper/helper.py +182 -0
- shikiru/helper/models.py +11 -0
- shikiru/mcp/__init__.py +1 -0
- shikiru/mcp/server.py +196 -0
- shikiru/operator/__init__.py +33 -0
- shikiru/operator/attachment.py +24 -0
- shikiru/operator/commands.py +319 -0
- shikiru/operator/detectors.py +374 -0
- shikiru/operator/doctor.py +351 -0
- shikiru/operator/events.py +57 -0
- shikiru/operator/evidence_export.py +498 -0
- shikiru/operator/evidence_writer.py +78 -0
- shikiru/operator/lifecycle.py +294 -0
- shikiru/operator/plugin_checks.py +194 -0
- shikiru/operator/probe.py +287 -0
- shikiru/operator/queries.py +1601 -0
- shikiru/operator/services.py +36 -0
- shikiru/operator/sessions.py +378 -0
- shikiru/operator/strip_views.py +162 -0
- shikiru/operator/timeline.py +416 -0
- shikiru/operator/toml_text.py +62 -0
- shikiru/palette.py +18 -0
- shikiru/paths.py +91 -0
- shikiru/plugins/__init__.py +0 -0
- shikiru/plugins/agents/__init__.py +1 -0
- shikiru/plugins/agents/claude/__init__.py +5 -0
- shikiru/plugins/agents/claude/agent.py +251 -0
- shikiru/plugins/agents/claude/context.py +116 -0
- shikiru/plugins/agents/claude/headless.py +92 -0
- shikiru/plugins/agents/claude/hook.py +57 -0
- shikiru/plugins/agents/claude/model_catalog.py +105 -0
- shikiru/plugins/agents/claude/options.py +62 -0
- shikiru/plugins/agents/claude/turn.py +1018 -0
- shikiru/plugins/agents/codex/__init__.py +5 -0
- shikiru/plugins/agents/codex/agent.py +486 -0
- shikiru/plugins/agents/codex/headless.py +166 -0
- shikiru/plugins/agents/codex/home.py +14 -0
- shikiru/plugins/agents/codex/model_catalog.py +128 -0
- shikiru/plugins/agents/codex/options.py +94 -0
- shikiru/plugins/agents/codex/quota.py +195 -0
- shikiru/plugins/agents/codex/sandbox.py +31 -0
- shikiru/plugins/agents/codex/sandbox_probe.py +97 -0
- shikiru/plugins/agents/codex/turn.py +767 -0
- shikiru/plugins/agents/codex/usage.py +106 -0
- shikiru/plugins/agents/shared_settings.py +62 -0
- shikiru/plugins/widgets/__init__.py +3 -0
- shikiru/plugins/widgets/dialogue.py +144 -0
- shikiru/plugins/widgets/disk.py +123 -0
- shikiru/plugins/widgets/docker.py +225 -0
- shikiru/plugins/widgets/escalations.py +402 -0
- shikiru/plugins/widgets/faults.py +204 -0
- shikiru/plugins/widgets/header.py +321 -0
- shikiru/plugins/widgets/queue.py +1140 -0
- shikiru/plugins/widgets/queue_hint.py +93 -0
- shikiru/plugins/widgets/resources.py +105 -0
- shikiru/plugins/widgets/strips.py +82 -0
- shikiru/plugins/widgets/usage.py +176 -0
- shikiru/private.py +94 -0
- shikiru/process.py +535 -0
- shikiru/prompts/__init__.py +3 -0
- shikiru/prompts/renderer.py +85 -0
- shikiru/prompts/templates/_base.md.j2 +17 -0
- shikiru/prompts/templates/_macros.md.j2 +61 -0
- shikiru/prompts/templates/conflicts.md.j2 +22 -0
- shikiru/prompts/templates/consult.md.j2 +5 -0
- shikiru/prompts/templates/continue.md.j2 +4 -0
- shikiru/prompts/templates/feedback_analysis.md.j2 +29 -0
- shikiru/prompts/templates/feedback_rework.md.j2 +33 -0
- shikiru/prompts/templates/feedback_verify.md.j2 +33 -0
- shikiru/prompts/templates/handoff.md.j2 +23 -0
- shikiru/prompts/templates/implement.md.j2 +13 -0
- shikiru/prompts/templates/plan.md.j2 +30 -0
- shikiru/prompts/templates/rework.md.j2 +13 -0
- shikiru/prompts/templates/triage.md.j2 +20 -0
- shikiru/prompts/templates/verify.md.j2 +29 -0
- shikiru/store/__init__.py +1 -0
- shikiru/store/commands.py +242 -0
- shikiru/store/database.py +297 -0
- shikiru/store/escalations.py +240 -0
- shikiru/store/events.py +213 -0
- shikiru/store/feedback.py +266 -0
- shikiru/store/migrations/001_initial.sql +63 -0
- shikiru/store/migrations/002_headless_turns.sql +31 -0
- shikiru/store/migrations/003_drop_command_handled_at.sql +1 -0
- shikiru/store/migrations/004_repo_base_ref.sql +3 -0
- shikiru/store/migrations/005_complexity_explicit.sql +3 -0
- shikiru/store/migrations/006_events_kind_index.sql +1 -0
- shikiru/store/migrations/007_notification_cursor.sql +1 -0
- shikiru/store/migrations/008_reported_usage.sql +1 -0
- shikiru/store/migrations/009_billing_declarations.sql +2 -0
- shikiru/store/migrations/010_canonical_quota_windows.sql +3 -0
- shikiru/store/migrations/012_drop_task_quota_evidence.sql +2 -0
- shikiru/store/migrations/013_push_targets.sql +7 -0
- shikiru/store/migrations/014_agent_block_time.sql +6 -0
- shikiru/store/migrations/015_repair_orphan_agent_blocks.sql +3 -0
- shikiru/store/migrations/016_feedback_rounds.sql +29 -0
- shikiru/store/migrations/017_feedback_round_comments.sql +5 -0
- shikiru/store/migrations/__init__.py +1 -0
- shikiru/store/notification_cursor.py +29 -0
- shikiru/store/pushes.py +117 -0
- shikiru/store/quotas.py +153 -0
- shikiru/store/reader.py +310 -0
- shikiru/store/retention.py +92 -0
- shikiru/store/runs.py +227 -0
- shikiru/store/tasks.py +949 -0
- shikiru/telemetry/__init__.py +1 -0
- shikiru/telemetry/disk.py +167 -0
- shikiru/telemetry/docker.py +164 -0
- shikiru/telemetry/resources.py +296 -0
- shikiru/tmux/__init__.py +3 -0
- shikiru/tmux/client.py +637 -0
- shikiru/tmux/decorator.py +225 -0
- shikiru/tmux/layout.py +200 -0
- shikiru/tmux/session.py +381 -0
- shikiru/ui/__init__.py +9 -0
- shikiru/ui/age.py +45 -0
- shikiru/ui/bubbles.py +41 -0
- shikiru/ui/compact.py +1209 -0
- shikiru/ui/console.py +49 -0
- shikiru/ui/fold.py +50 -0
- shikiru/ui/gated_static.py +117 -0
- shikiru/ui/gauge.py +40 -0
- shikiru/ui/glyphs.py +109 -0
- shikiru/ui/keys.py +57 -0
- shikiru/ui/live_interval.py +42 -0
- shikiru/ui/popup.py +311 -0
- shikiru/ui/quota_status.py +26 -0
- shikiru/ui/recent_window.py +78 -0
- shikiru/ui/screen.py +57 -0
- shikiru/ui/section.py +193 -0
- shikiru/ui/session.py +65 -0
- shikiru/ui/size.py +17 -0
- shikiru/ui/splash.py +42 -0
- shikiru/ui/status.py +47 -0
- shikiru/ui/status_strip.py +149 -0
- shikiru/ui/stream.py +152 -0
- shikiru/ui/strip.py +332 -0
- shikiru/ui/theme.py +83 -0
- shikiru/ui/tokens.py +51 -0
- shikiru/view_log.py +126 -0
- shikiru/workspace/__init__.py +3 -0
- shikiru/workspace/base_refs.py +160 -0
- shikiru/workspace/caches.py +136 -0
- shikiru/workspace/facts.py +118 -0
- shikiru/workspace/git.py +601 -0
- shikiru/workspace/github.py +744 -0
- shikiru/workspace/messages.py +107 -0
- shikiru/workspace/reports.py +145 -0
- shikiru/workspace/roots.py +180 -0
- shikiru_cli-0.2.0.dist-info/METADATA +428 -0
- shikiru_cli-0.2.0.dist-info/RECORD +319 -0
- shikiru_cli-0.2.0.dist-info/WHEEL +4 -0
- shikiru_cli-0.2.0.dist-info/entry_points.txt +18 -0
- shikiru_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1018 @@
|
|
|
1
|
+
"""Claude Code stream-json turn parser and process sequence."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import uuid
|
|
8
|
+
from collections.abc import Mapping
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
11
|
+
|
|
12
|
+
from shikiru.agents.contracts import (
|
|
13
|
+
PERCENT,
|
|
14
|
+
QUOTA_WINDOW_FIVE_HOUR,
|
|
15
|
+
QUOTA_WINDOW_SEVEN_DAY,
|
|
16
|
+
AgentSignal,
|
|
17
|
+
Compacted,
|
|
18
|
+
FailureKind,
|
|
19
|
+
Message,
|
|
20
|
+
QuotaReported,
|
|
21
|
+
QuotaWindow,
|
|
22
|
+
SessionStarted,
|
|
23
|
+
ToolFinished,
|
|
24
|
+
ToolOutcome,
|
|
25
|
+
ToolStarted,
|
|
26
|
+
TurnEnded,
|
|
27
|
+
TurnFailed,
|
|
28
|
+
TurnSpec,
|
|
29
|
+
Usage,
|
|
30
|
+
UsageReported,
|
|
31
|
+
module_command,
|
|
32
|
+
)
|
|
33
|
+
from shikiru.agents.sdk.turn import (
|
|
34
|
+
EMPTY_OUTPUT,
|
|
35
|
+
TurnProcess,
|
|
36
|
+
TurnProcessResult,
|
|
37
|
+
TurnProcessSequence,
|
|
38
|
+
)
|
|
39
|
+
from shikiru.plugins.agents.claude.context import (
|
|
40
|
+
compact_limit_tokens,
|
|
41
|
+
read_transcript,
|
|
42
|
+
transcript_context_tokens,
|
|
43
|
+
)
|
|
44
|
+
from shikiru.plugins.agents.claude.options import (
|
|
45
|
+
ADD_DIR_FLAG,
|
|
46
|
+
CLAUDE_COMMAND,
|
|
47
|
+
EFFORT_FLAG,
|
|
48
|
+
MODEL_FLAG,
|
|
49
|
+
OUTPUT_FORMAT_FLAG,
|
|
50
|
+
PRINT_FLAG,
|
|
51
|
+
RESUME_FLAG,
|
|
52
|
+
SESSION_ID_FLAG,
|
|
53
|
+
SETTINGS_FLAG,
|
|
54
|
+
STATUS_FORBIDDEN,
|
|
55
|
+
STATUS_PAYMENT_REQUIRED,
|
|
56
|
+
STATUS_TOO_MANY_REQUESTS,
|
|
57
|
+
STATUS_UNAUTHORIZED,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
if TYPE_CHECKING:
|
|
61
|
+
from collections.abc import Callable, Sequence
|
|
62
|
+
from datetime import datetime
|
|
63
|
+
|
|
64
|
+
from shikiru.agents.sdk.reset_time import ResetTimeParser
|
|
65
|
+
from shikiru.clock import Clock
|
|
66
|
+
|
|
67
|
+
_STREAM_JSON = "stream-json"
|
|
68
|
+
_VERBOSE_FLAG = "--verbose"
|
|
69
|
+
_COMPACT_PROMPT = "/compact"
|
|
70
|
+
CONTINUE_PROMPT = "Continue the task exactly where you stopped."
|
|
71
|
+
HOOK_MODULE = "shikiru.plugins.agents.claude.hook"
|
|
72
|
+
COMPACT_MARKER_SUFFIX = ".compact-marker"
|
|
73
|
+
COMPACT_STATE_SUFFIX = ".compact-state"
|
|
74
|
+
CLAUDE_CONFIG_DIR_ENV_VAR = "CLAUDE_CONFIG_DIR"
|
|
75
|
+
HOME_ENV_VAR = "HOME"
|
|
76
|
+
CLAUDE_DIRNAME = ".claude"
|
|
77
|
+
PROJECTS_DIRNAME = "projects"
|
|
78
|
+
TRANSCRIPT_SUFFIX = ".jsonl"
|
|
79
|
+
_HOOKS_FIELD = "hooks"
|
|
80
|
+
_POST_TOOL_USE_EVENT = "PostToolUse"
|
|
81
|
+
_HOOK_MATCHER_FIELD = "matcher"
|
|
82
|
+
_ANY_TOOL_MATCHER = "*"
|
|
83
|
+
_HOOK_TYPE_FIELD = "type"
|
|
84
|
+
_COMMAND_HOOK_TYPE = "command"
|
|
85
|
+
_HOOK_COMMAND_FIELD = "command"
|
|
86
|
+
_SYSTEM_TYPE = "system"
|
|
87
|
+
_ASSISTANT_TYPE = "assistant"
|
|
88
|
+
_USER_TYPE = "user"
|
|
89
|
+
_RESULT_TYPE = "result"
|
|
90
|
+
_RATE_LIMIT_TYPE = "rate_limit_event"
|
|
91
|
+
_INIT_SUBTYPE = "init"
|
|
92
|
+
_COMPACT_BOUNDARY_SUBTYPE = "compact_boundary"
|
|
93
|
+
_SUCCESS_SUBTYPE = "success"
|
|
94
|
+
_ERROR_TERMINAL_REASONS = (
|
|
95
|
+
"error",
|
|
96
|
+
"api_error",
|
|
97
|
+
"prompt_too_long",
|
|
98
|
+
"context_length_exceeded",
|
|
99
|
+
)
|
|
100
|
+
_LIST_COST_BASIS = "list"
|
|
101
|
+
_EMPTY_RESULT = ""
|
|
102
|
+
_TEXT_BLOCK_TYPE = "text"
|
|
103
|
+
_TOOL_USE_BLOCK_TYPE = "tool_use"
|
|
104
|
+
_TOOL_RESULT_BLOCK_TYPE = "tool_result"
|
|
105
|
+
_TEXT_FIELD = "text"
|
|
106
|
+
_CONTENT_FIELD = "content"
|
|
107
|
+
_MESSAGE_FIELD = "message"
|
|
108
|
+
_USAGE_FIELD = "usage"
|
|
109
|
+
_INPUT_TOKENS_FIELD = "input_tokens"
|
|
110
|
+
_OUTPUT_TOKENS_FIELD = "output_tokens"
|
|
111
|
+
_CACHE_READ_TOKENS_FIELD = "cache_read_input_tokens"
|
|
112
|
+
_CACHE_WRITE_TOKENS_FIELD = "cache_creation_input_tokens"
|
|
113
|
+
_SESSION_ID_FIELD = "session_id"
|
|
114
|
+
_MODEL_FIELD = "model"
|
|
115
|
+
_MODEL_USAGE_FIELD = "modelUsage"
|
|
116
|
+
_MODEL_USAGE_DELTA_FIELD = "model_usage_since_last_result"
|
|
117
|
+
_INPUT_TOKENS_DELTA_FIELD = "inputTokens"
|
|
118
|
+
_OUTPUT_TOKENS_DELTA_FIELD = "outputTokens"
|
|
119
|
+
_CACHE_READ_TOKENS_DELTA_FIELD = "cacheReadInputTokens"
|
|
120
|
+
_CACHE_WRITE_TOKENS_DELTA_FIELD = "cacheCreationInputTokens"
|
|
121
|
+
_CONTEXT_WINDOW_FIELD = "contextWindow"
|
|
122
|
+
_COST_BASIS_FIELD = "costBasis"
|
|
123
|
+
_COST_USD_FIELD = "costUSD"
|
|
124
|
+
_TOTAL_COST_FIELD = "total_cost_usd"
|
|
125
|
+
_COMPACT_METADATA_FIELD = "compact_metadata"
|
|
126
|
+
_ORIGIN_FIELD = "origin"
|
|
127
|
+
_ORIGIN_KIND_FIELD = "kind"
|
|
128
|
+
_TASK_NOTIFICATION_ORIGIN_KIND = "task-notification"
|
|
129
|
+
_TASK_STARTED_SUBTYPE = "task_started"
|
|
130
|
+
_TASK_UPDATED_SUBTYPE = "task_updated"
|
|
131
|
+
_TASK_NOTIFICATION_SUBTYPE = "task_notification"
|
|
132
|
+
_TASK_ID_FIELD = "task_id"
|
|
133
|
+
_TASK_DESCRIPTION_FIELD = "description"
|
|
134
|
+
_TASK_SUMMARY_FIELD = "summary"
|
|
135
|
+
_TASK_STATUS_FIELD = "status"
|
|
136
|
+
_TASK_PATCH_FIELD = "patch"
|
|
137
|
+
_IS_BACKGROUNDED_FIELD = "is_backgrounded"
|
|
138
|
+
_RESULT_INDEX_FIELD = "result_index"
|
|
139
|
+
_STOP_REASON_FIELD = "stop_reason"
|
|
140
|
+
_PRE_TOKENS_FIELD = "pre_tokens"
|
|
141
|
+
_POST_TOKENS_FIELD = "post_tokens"
|
|
142
|
+
_RATE_LIMIT_INFO_FIELD = "rate_limit_info"
|
|
143
|
+
_UNIFIED_WINDOWS_FIELD = "unifiedWindows"
|
|
144
|
+
_FIVE_HOUR_PROTOCOL_KEY = "five_hour"
|
|
145
|
+
_SEVEN_DAY_PROTOCOL_KEY = "seven_day"
|
|
146
|
+
_QUOTA_WINDOW_NAMES = {
|
|
147
|
+
_FIVE_HOUR_PROTOCOL_KEY: QUOTA_WINDOW_FIVE_HOUR,
|
|
148
|
+
_SEVEN_DAY_PROTOCOL_KEY: QUOTA_WINDOW_SEVEN_DAY,
|
|
149
|
+
}
|
|
150
|
+
_UTILIZATION_FIELD = "utilization"
|
|
151
|
+
_RESETS_AT_FIELD = "resetsAt"
|
|
152
|
+
_API_ERROR_STATUS_FIELD = "api_error_status"
|
|
153
|
+
_STATUS_FIELD = "status"
|
|
154
|
+
_IS_ERROR_FIELD = "is_error"
|
|
155
|
+
_TERMINAL_REASON_FIELD = "terminal_reason"
|
|
156
|
+
_RESULT_FIELD = "result"
|
|
157
|
+
_ERROR_FIELD = "error"
|
|
158
|
+
_ERRORS_FIELD = "errors"
|
|
159
|
+
_TOOL_NAME_FIELD = "name"
|
|
160
|
+
_TOOL_ID_FIELD = "id"
|
|
161
|
+
_TOOL_USE_ID_FIELD = "tool_use_id"
|
|
162
|
+
_TOOL_INPUT_FIELD = "input"
|
|
163
|
+
_TYPE_FIELD = "type"
|
|
164
|
+
_SUBTYPE_FIELD = "subtype"
|
|
165
|
+
_MESSAGE_TEXT_FIELD = "message"
|
|
166
|
+
_ERROR_TAG_PATTERN = re.compile(r"\[claude-code:(?P<code>[a-z0-9_-]+)\]", re.IGNORECASE)
|
|
167
|
+
_ERROR_STATUSES = (
|
|
168
|
+
STATUS_UNAUTHORIZED,
|
|
169
|
+
STATUS_PAYMENT_REQUIRED,
|
|
170
|
+
STATUS_FORBIDDEN,
|
|
171
|
+
STATUS_TOO_MANY_REQUESTS,
|
|
172
|
+
)
|
|
173
|
+
_UNAVAILABLE_STATUS_MIN = 500
|
|
174
|
+
_UNAVAILABLE_STATUS_MAX = 599
|
|
175
|
+
_CONTEXT_ERROR_TEXTS = (
|
|
176
|
+
"prompt is too long",
|
|
177
|
+
"prompt_too_long",
|
|
178
|
+
"context length",
|
|
179
|
+
"context_length_exceeded",
|
|
180
|
+
"context window",
|
|
181
|
+
)
|
|
182
|
+
_QUOTA_ERROR_TEXTS = ("usage limit", "rate limit", "too many requests")
|
|
183
|
+
_UNAVAILABLE_ERROR_TEXTS = ("overloaded", "unrecognized_model", "unrecognized model")
|
|
184
|
+
_AUTH_ERROR_TEXTS = ("authentication", "unauthorized", "invalid api key")
|
|
185
|
+
_TOOL_DETAIL_MAX_CHARS = 160
|
|
186
|
+
_UNKNOWN_ERROR_MESSAGE = "Claude stream ended without a result"
|
|
187
|
+
_PROCESS_FAILURE_MESSAGE = "Claude process exited unsuccessfully after emitting a result"
|
|
188
|
+
_INVALID_COMPACTION_MESSAGE = "Claude compaction ended without compact_boundary"
|
|
189
|
+
_MALFORMED_COMPACTION_MESSAGE = "Claude compaction reported malformed token counts"
|
|
190
|
+
_INEFFECTIVE_COMPACTION_MESSAGE = (
|
|
191
|
+
"Claude compaction left {post_tokens} tokens, not below the {limit}-token compaction limit"
|
|
192
|
+
)
|
|
193
|
+
_MALFORMED_STREAM_MESSAGE = "Claude emitted malformed stream-json"
|
|
194
|
+
_STATUS_MESSAGE = "Claude returned API status {status}"
|
|
195
|
+
_STDERR_MESSAGE = "Claude exited with status {exit_code}: {message}"
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class ClaudeTurnParser:
|
|
199
|
+
"""Map Claude's stream-json records and process exit to agent signals."""
|
|
200
|
+
|
|
201
|
+
def __init__(
|
|
202
|
+
self,
|
|
203
|
+
*,
|
|
204
|
+
session_id: str,
|
|
205
|
+
model: str,
|
|
206
|
+
initial_usage: Usage | None,
|
|
207
|
+
clock: Clock,
|
|
208
|
+
reset_time: ResetTimeParser,
|
|
209
|
+
compact_limit_tokens: int | None = None,
|
|
210
|
+
) -> None:
|
|
211
|
+
self._session_id = session_id
|
|
212
|
+
self._model = model
|
|
213
|
+
self._initial_usage = initial_usage or _empty_usage()
|
|
214
|
+
self._clock = clock
|
|
215
|
+
self._reset_time = reset_time
|
|
216
|
+
self._compact_limit_tokens = compact_limit_tokens
|
|
217
|
+
self._tool_names: dict[str, str] = {}
|
|
218
|
+
self._tool_ids: list[str] = []
|
|
219
|
+
self._latest_assistant_usage: Mapping[str, Any] | None = None
|
|
220
|
+
self._latest_model_usage: Mapping[str, Any] | None = None
|
|
221
|
+
self._latest_cost_basis: str | None = None
|
|
222
|
+
self._latest_cost: float | None = None
|
|
223
|
+
self._latest_message = ""
|
|
224
|
+
self._record_order = 0
|
|
225
|
+
self._latest_message_order = -1
|
|
226
|
+
self._latest_result_order = -1
|
|
227
|
+
self._saw_result_usage = False
|
|
228
|
+
self._result_input_tokens = 0
|
|
229
|
+
self._result_output_tokens = 0
|
|
230
|
+
self._result_cache_read_tokens = 0
|
|
231
|
+
self._result_cache_write_tokens = 0
|
|
232
|
+
self._session_started = False
|
|
233
|
+
self._started_session_id: str | None = None
|
|
234
|
+
self._pending_activity: list[AgentSignal] = []
|
|
235
|
+
self._latest_quota: tuple[QuotaWindow, ...] = ()
|
|
236
|
+
self._terminal = False
|
|
237
|
+
self._malformed_line: str | None = None
|
|
238
|
+
self._did_compact = False
|
|
239
|
+
self._post_compaction_tokens: int | None = None
|
|
240
|
+
self._malformed_compaction_metadata = False
|
|
241
|
+
self._failure_payload: Mapping[str, Any] | None = None
|
|
242
|
+
self._success_payload: Mapping[str, Any] | None = None
|
|
243
|
+
self._context_exceeded = False
|
|
244
|
+
|
|
245
|
+
@property
|
|
246
|
+
def context_exceeded(self) -> bool:
|
|
247
|
+
"""Return whether this process failed because its context was full."""
|
|
248
|
+
return self._context_exceeded
|
|
249
|
+
|
|
250
|
+
def feed(self, line: str) -> list[AgentSignal]:
|
|
251
|
+
if self._terminal:
|
|
252
|
+
return []
|
|
253
|
+
try:
|
|
254
|
+
raw = json.loads(line)
|
|
255
|
+
except json.JSONDecodeError:
|
|
256
|
+
self._malformed_line = self._malformed_line or line
|
|
257
|
+
return []
|
|
258
|
+
if not isinstance(raw, Mapping):
|
|
259
|
+
self._malformed_line = self._malformed_line or line
|
|
260
|
+
return []
|
|
261
|
+
payload = cast("Mapping[str, Any]", raw)
|
|
262
|
+
self._record_order += 1
|
|
263
|
+
handlers = {
|
|
264
|
+
_SYSTEM_TYPE: self._system,
|
|
265
|
+
_ASSISTANT_TYPE: self._assistant,
|
|
266
|
+
_USER_TYPE: self._user,
|
|
267
|
+
_RATE_LIMIT_TYPE: self._rate_limit,
|
|
268
|
+
_RESULT_TYPE: self._result,
|
|
269
|
+
}
|
|
270
|
+
handler = handlers.get(cast("str", payload.get(_TYPE_FIELD)))
|
|
271
|
+
return handler(payload) if handler is not None else []
|
|
272
|
+
|
|
273
|
+
def finish(
|
|
274
|
+
self, exit_code: int, stderr: str, *, interrupted: bool = False
|
|
275
|
+
) -> list[AgentSignal]:
|
|
276
|
+
if self._terminal:
|
|
277
|
+
return []
|
|
278
|
+
if self._malformed_line is not None:
|
|
279
|
+
self._terminal = True
|
|
280
|
+
return [
|
|
281
|
+
TurnFailed(
|
|
282
|
+
FailureKind.UNKNOWN,
|
|
283
|
+
_stderr_message(exit_code, stderr, _MALFORMED_STREAM_MESSAGE),
|
|
284
|
+
None,
|
|
285
|
+
)
|
|
286
|
+
]
|
|
287
|
+
if self._failure_payload is not None:
|
|
288
|
+
self._terminal = True
|
|
289
|
+
return self._fail(self._failure_payload, stderr)
|
|
290
|
+
if self._success_payload is not None:
|
|
291
|
+
if exit_code != 0:
|
|
292
|
+
return self._finish_failed_process(exit_code, stderr)
|
|
293
|
+
return self._finish_success(self._success_payload)
|
|
294
|
+
return self._finish_without_result(exit_code, stderr, interrupted=interrupted)
|
|
295
|
+
|
|
296
|
+
def _finish_success(self, payload: Mapping[str, Any]) -> list[AgentSignal]:
|
|
297
|
+
compaction_problem = self._compaction_problem()
|
|
298
|
+
if compaction_problem is not None:
|
|
299
|
+
self._terminal = True
|
|
300
|
+
return [TurnFailed(FailureKind.OTHER, compaction_problem, None)]
|
|
301
|
+
usage = self._reported_usage()
|
|
302
|
+
result_text = payload.get(_RESULT_FIELD)
|
|
303
|
+
if self._latest_message_order > self._latest_result_order:
|
|
304
|
+
last_message = self._latest_message
|
|
305
|
+
elif isinstance(result_text, str):
|
|
306
|
+
last_message = result_text
|
|
307
|
+
else:
|
|
308
|
+
last_message = self._latest_message
|
|
309
|
+
self._terminal = True
|
|
310
|
+
signals: list[AgentSignal] = []
|
|
311
|
+
if usage is not None:
|
|
312
|
+
signals.append(UsageReported(usage))
|
|
313
|
+
signals.append(TurnEnded(last_message))
|
|
314
|
+
return signals
|
|
315
|
+
|
|
316
|
+
def _finish_without_result(
|
|
317
|
+
self, exit_code: int, stderr: str, *, interrupted: bool = False
|
|
318
|
+
) -> list[AgentSignal]:
|
|
319
|
+
failure = self._classify_failure(None, stderr)
|
|
320
|
+
if failure is not None:
|
|
321
|
+
kind, message, resets_at = failure
|
|
322
|
+
self._terminal = True
|
|
323
|
+
return [TurnFailed(kind, _stderr_message(exit_code, stderr, message), resets_at)]
|
|
324
|
+
compaction_problem = self._compaction_problem()
|
|
325
|
+
if interrupted and compaction_problem is None:
|
|
326
|
+
self._terminal = True
|
|
327
|
+
return []
|
|
328
|
+
message = compaction_problem or _UNKNOWN_ERROR_MESSAGE
|
|
329
|
+
kind = FailureKind.OTHER if compaction_problem is not None else FailureKind.UNKNOWN
|
|
330
|
+
self._terminal = True
|
|
331
|
+
return [TurnFailed(kind, _stderr_message(exit_code, stderr, message), None)]
|
|
332
|
+
|
|
333
|
+
def _finish_failed_process(self, exit_code: int, stderr: str) -> list[AgentSignal]:
|
|
334
|
+
failure = self._classify_failure(None, stderr)
|
|
335
|
+
kind, message, resets_at = failure or (
|
|
336
|
+
FailureKind.UNKNOWN,
|
|
337
|
+
_PROCESS_FAILURE_MESSAGE,
|
|
338
|
+
None,
|
|
339
|
+
)
|
|
340
|
+
self._terminal = True
|
|
341
|
+
return [TurnFailed(kind, _stderr_message(exit_code, stderr, message), resets_at)]
|
|
342
|
+
|
|
343
|
+
def _system(self, payload: Mapping[str, Any]) -> list[AgentSignal]:
|
|
344
|
+
subtype = payload.get(_SUBTYPE_FIELD)
|
|
345
|
+
if subtype == _INIT_SUBTYPE:
|
|
346
|
+
return self._init_session(payload)
|
|
347
|
+
if subtype == _COMPACT_BOUNDARY_SUBTYPE:
|
|
348
|
+
return self._compact_boundary(payload)
|
|
349
|
+
return self._system_activity(payload)
|
|
350
|
+
|
|
351
|
+
def _init_session(self, payload: Mapping[str, Any]) -> list[AgentSignal]:
|
|
352
|
+
raw_session_id = payload.get(_SESSION_ID_FIELD)
|
|
353
|
+
if not isinstance(raw_session_id, str) or not raw_session_id.strip():
|
|
354
|
+
return []
|
|
355
|
+
session_id = raw_session_id
|
|
356
|
+
model = payload.get(_MODEL_FIELD)
|
|
357
|
+
if isinstance(model, str):
|
|
358
|
+
self._model = model
|
|
359
|
+
if self._session_started and session_id == self._started_session_id:
|
|
360
|
+
return []
|
|
361
|
+
if self._session_started:
|
|
362
|
+
return [SessionStarted(session_id)]
|
|
363
|
+
self._session_started = True
|
|
364
|
+
self._started_session_id = session_id
|
|
365
|
+
pending, self._pending_activity = self._pending_activity, []
|
|
366
|
+
return [SessionStarted(session_id), *pending]
|
|
367
|
+
|
|
368
|
+
def _compact_boundary(self, payload: Mapping[str, Any]) -> list[AgentSignal]:
|
|
369
|
+
self._did_compact = True
|
|
370
|
+
metadata = _mapping(payload.get(_COMPACT_METADATA_FIELD))
|
|
371
|
+
if metadata is not None:
|
|
372
|
+
self._malformed_compaction_metadata |= any(
|
|
373
|
+
key in metadata and _optional_nonnegative_integer(metadata, key) is None
|
|
374
|
+
for key in (_PRE_TOKENS_FIELD, _POST_TOKENS_FIELD)
|
|
375
|
+
)
|
|
376
|
+
self._post_compaction_tokens = _optional_nonnegative_integer(metadata, _POST_TOKENS_FIELD)
|
|
377
|
+
if self._malformed_compaction_metadata:
|
|
378
|
+
return []
|
|
379
|
+
return [
|
|
380
|
+
Compacted(
|
|
381
|
+
_optional_nonnegative_integer(metadata, _PRE_TOKENS_FIELD),
|
|
382
|
+
self._post_compaction_tokens,
|
|
383
|
+
)
|
|
384
|
+
]
|
|
385
|
+
|
|
386
|
+
def _system_activity(self, payload: Mapping[str, Any]) -> list[AgentSignal]:
|
|
387
|
+
activity = self._background_activity(payload)
|
|
388
|
+
if not activity:
|
|
389
|
+
return []
|
|
390
|
+
if self._session_started:
|
|
391
|
+
return activity
|
|
392
|
+
self._pending_activity.extend(activity)
|
|
393
|
+
return []
|
|
394
|
+
|
|
395
|
+
def _background_activity(self, payload: Mapping[str, Any]) -> list[AgentSignal]:
|
|
396
|
+
subtype = payload.get(_SUBTYPE_FIELD)
|
|
397
|
+
if subtype == _TASK_STARTED_SUBTYPE:
|
|
398
|
+
description = payload.get(_TASK_DESCRIPTION_FIELD)
|
|
399
|
+
if payload.get(_IS_BACKGROUNDED_FIELD) is True and isinstance(description, str):
|
|
400
|
+
return [Message(description)] if description else []
|
|
401
|
+
if subtype == _TASK_NOTIFICATION_SUBTYPE:
|
|
402
|
+
summary = payload.get(_TASK_SUMMARY_FIELD)
|
|
403
|
+
if isinstance(summary, str) and summary:
|
|
404
|
+
return [Message(summary)]
|
|
405
|
+
if subtype == _TASK_UPDATED_SUBTYPE:
|
|
406
|
+
patch = _mapping(payload.get(_TASK_PATCH_FIELD))
|
|
407
|
+
status = patch.get(_TASK_STATUS_FIELD) if patch is not None else None
|
|
408
|
+
task_id = payload.get(_TASK_ID_FIELD)
|
|
409
|
+
if isinstance(status, str) and isinstance(task_id, str):
|
|
410
|
+
return [Message(f"Background task {task_id}: {status}")]
|
|
411
|
+
return []
|
|
412
|
+
|
|
413
|
+
def _assistant(self, payload: Mapping[str, Any]) -> list[AgentSignal]:
|
|
414
|
+
blocks = self._assistant_content(payload)
|
|
415
|
+
if not isinstance(blocks, list):
|
|
416
|
+
return []
|
|
417
|
+
signals: list[AgentSignal] = []
|
|
418
|
+
for block in cast("list[object]", blocks):
|
|
419
|
+
signals.extend(self._assistant_block(block))
|
|
420
|
+
return signals
|
|
421
|
+
|
|
422
|
+
def _assistant_content(self, payload: Mapping[str, Any]) -> object:
|
|
423
|
+
message = _mapping(payload.get(_MESSAGE_FIELD))
|
|
424
|
+
if message is not None:
|
|
425
|
+
usage = _mapping(message.get(_USAGE_FIELD))
|
|
426
|
+
if usage is not None:
|
|
427
|
+
self._latest_assistant_usage = usage
|
|
428
|
+
return message.get(_CONTENT_FIELD)
|
|
429
|
+
return payload.get(_CONTENT_FIELD)
|
|
430
|
+
|
|
431
|
+
def _assistant_block(self, block: object) -> list[AgentSignal]:
|
|
432
|
+
item = _mapping(block)
|
|
433
|
+
if item is None:
|
|
434
|
+
return []
|
|
435
|
+
if item.get(_TYPE_FIELD) == _TEXT_BLOCK_TYPE:
|
|
436
|
+
text = item.get(_TEXT_FIELD)
|
|
437
|
+
if isinstance(text, str) and text:
|
|
438
|
+
self._latest_message = text
|
|
439
|
+
self._latest_message_order = self._record_order
|
|
440
|
+
return [Message(text)]
|
|
441
|
+
if item.get(_TYPE_FIELD) == _TOOL_USE_BLOCK_TYPE:
|
|
442
|
+
return self._tool_started(item)
|
|
443
|
+
return []
|
|
444
|
+
|
|
445
|
+
def _tool_started(self, item: Mapping[str, Any]) -> list[AgentSignal]:
|
|
446
|
+
name = item.get(_TOOL_NAME_FIELD)
|
|
447
|
+
if not isinstance(name, str):
|
|
448
|
+
return []
|
|
449
|
+
signals: list[AgentSignal] = [ToolStarted(name, _tool_detail(item.get(_TOOL_INPUT_FIELD)))]
|
|
450
|
+
tool_id = item.get(_TOOL_ID_FIELD)
|
|
451
|
+
if isinstance(tool_id, str):
|
|
452
|
+
self._tool_names[tool_id] = name
|
|
453
|
+
self._tool_ids.append(tool_id)
|
|
454
|
+
return signals
|
|
455
|
+
|
|
456
|
+
def _user(self, payload: Mapping[str, Any]) -> list[AgentSignal]:
|
|
457
|
+
message = _mapping(payload.get(_MESSAGE_FIELD))
|
|
458
|
+
blocks = message.get(_CONTENT_FIELD) if message is not None else None
|
|
459
|
+
if not isinstance(blocks, list):
|
|
460
|
+
return []
|
|
461
|
+
signals: list[AgentSignal] = []
|
|
462
|
+
for block in cast("list[object]", blocks):
|
|
463
|
+
item = _mapping(block)
|
|
464
|
+
if item is None or item.get(_TYPE_FIELD) != _TOOL_RESULT_BLOCK_TYPE:
|
|
465
|
+
continue
|
|
466
|
+
tool_id = item.get(_TOOL_USE_ID_FIELD)
|
|
467
|
+
if not isinstance(tool_id, str) and self._tool_ids:
|
|
468
|
+
tool_id = self._tool_ids[0]
|
|
469
|
+
if isinstance(tool_id, str):
|
|
470
|
+
name = self._tool_names.pop(tool_id, None)
|
|
471
|
+
if tool_id in self._tool_ids:
|
|
472
|
+
self._tool_ids.remove(tool_id)
|
|
473
|
+
if name is not None:
|
|
474
|
+
is_error = item.get(_IS_ERROR_FIELD)
|
|
475
|
+
outcome = (
|
|
476
|
+
ToolOutcome.FAILURE
|
|
477
|
+
if is_error is True
|
|
478
|
+
else ToolOutcome.SUCCESS
|
|
479
|
+
if is_error is False
|
|
480
|
+
else None
|
|
481
|
+
)
|
|
482
|
+
signals.append(ToolFinished(name, outcome))
|
|
483
|
+
return signals
|
|
484
|
+
|
|
485
|
+
def _rate_limit(self, payload: Mapping[str, Any]) -> list[AgentSignal]:
|
|
486
|
+
info = _mapping(payload.get(_RATE_LIMIT_INFO_FIELD))
|
|
487
|
+
if info is None:
|
|
488
|
+
return []
|
|
489
|
+
windows = _mapping(info.get(_UNIFIED_WINDOWS_FIELD)) or info
|
|
490
|
+
observed_at = self._clock.now()
|
|
491
|
+
reported = tuple(
|
|
492
|
+
self._quota_window(name, raw, observed_at)
|
|
493
|
+
for key, name in _QUOTA_WINDOW_NAMES.items()
|
|
494
|
+
if (raw := _mapping(windows.get(key))) is not None
|
|
495
|
+
and isinstance(raw.get(_UTILIZATION_FIELD), int | float)
|
|
496
|
+
)
|
|
497
|
+
if not reported:
|
|
498
|
+
return []
|
|
499
|
+
self._latest_quota = reported
|
|
500
|
+
return [QuotaReported(reported)]
|
|
501
|
+
|
|
502
|
+
def _quota_window(
|
|
503
|
+
self, name: str, raw: Mapping[str, Any], observed_at: datetime
|
|
504
|
+
) -> QuotaWindow:
|
|
505
|
+
reset_value = raw.get(_RESETS_AT_FIELD)
|
|
506
|
+
resets_at = self._reset_time.parse(str(reset_value)) if reset_value is not None else None
|
|
507
|
+
return QuotaWindow(
|
|
508
|
+
name=name,
|
|
509
|
+
used_pct=float(cast("int | float", raw[_UTILIZATION_FIELD])) * PERCENT,
|
|
510
|
+
resets_at=resets_at,
|
|
511
|
+
observed_at=observed_at,
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
def _result(self, payload: Mapping[str, Any]) -> list[AgentSignal]:
|
|
515
|
+
if self._malformed_line is not None:
|
|
516
|
+
self._terminal = True
|
|
517
|
+
return [TurnFailed(FailureKind.UNKNOWN, _MALFORMED_STREAM_MESSAGE, None)]
|
|
518
|
+
if _is_error_result(payload):
|
|
519
|
+
self._failure_payload = payload
|
|
520
|
+
return []
|
|
521
|
+
if _is_no_work_task_notification_result(payload, self._model):
|
|
522
|
+
return []
|
|
523
|
+
self._success_payload = payload
|
|
524
|
+
self._latest_result_order = self._record_order
|
|
525
|
+
self._accumulate_result_usage(payload)
|
|
526
|
+
model_usage = _mapping(payload.get(_MODEL_USAGE_FIELD))
|
|
527
|
+
selected_model_usage = _select_model_usage(model_usage, self._model)
|
|
528
|
+
if selected_model_usage is not None:
|
|
529
|
+
latest_model_usage = dict(self._latest_model_usage or {})
|
|
530
|
+
latest_model_usage.update(selected_model_usage)
|
|
531
|
+
self._latest_model_usage = latest_model_usage
|
|
532
|
+
cost_basis = selected_model_usage.get(_COST_BASIS_FIELD)
|
|
533
|
+
if isinstance(cost_basis, str):
|
|
534
|
+
self._latest_cost_basis = cost_basis
|
|
535
|
+
total_cost = payload.get(_TOTAL_COST_FIELD)
|
|
536
|
+
if isinstance(total_cost, int | float) and not isinstance(total_cost, bool):
|
|
537
|
+
self._latest_cost = float(total_cost)
|
|
538
|
+
elif selected_model_usage is not None:
|
|
539
|
+
model_cost = selected_model_usage.get(_COST_USD_FIELD)
|
|
540
|
+
if isinstance(model_cost, int | float) and not isinstance(model_cost, bool):
|
|
541
|
+
self._latest_cost = float(model_cost)
|
|
542
|
+
return []
|
|
543
|
+
|
|
544
|
+
def _accumulate_result_usage(self, payload: Mapping[str, Any]) -> None:
|
|
545
|
+
source = _result_usage_delta(payload, self._model)
|
|
546
|
+
if source is None:
|
|
547
|
+
return
|
|
548
|
+
self._saw_result_usage = True
|
|
549
|
+
self._result_input_tokens += _integer(source, _INPUT_TOKENS_FIELD)
|
|
550
|
+
self._result_output_tokens += _integer(source, _OUTPUT_TOKENS_FIELD)
|
|
551
|
+
self._result_cache_read_tokens += _integer(source, _CACHE_READ_TOKENS_FIELD)
|
|
552
|
+
self._result_cache_write_tokens += _integer(source, _CACHE_WRITE_TOKENS_FIELD)
|
|
553
|
+
|
|
554
|
+
def _reported_usage(self) -> Usage | None:
|
|
555
|
+
source = self._latest_assistant_usage
|
|
556
|
+
token_fields = (
|
|
557
|
+
_INPUT_TOKENS_FIELD,
|
|
558
|
+
_OUTPUT_TOKENS_FIELD,
|
|
559
|
+
_CACHE_READ_TOKENS_FIELD,
|
|
560
|
+
_CACHE_WRITE_TOKENS_FIELD,
|
|
561
|
+
)
|
|
562
|
+
if not self._saw_result_usage and (
|
|
563
|
+
source is None or not any(field in source for field in token_fields)
|
|
564
|
+
):
|
|
565
|
+
return None
|
|
566
|
+
input_delta = (
|
|
567
|
+
self._result_input_tokens
|
|
568
|
+
if self._saw_result_usage
|
|
569
|
+
else _integer(source, _INPUT_TOKENS_FIELD)
|
|
570
|
+
)
|
|
571
|
+
output_delta = (
|
|
572
|
+
self._result_output_tokens
|
|
573
|
+
if self._saw_result_usage
|
|
574
|
+
else _integer(source, _OUTPUT_TOKENS_FIELD)
|
|
575
|
+
)
|
|
576
|
+
cache_read_delta = (
|
|
577
|
+
self._result_cache_read_tokens
|
|
578
|
+
if self._saw_result_usage
|
|
579
|
+
else _integer(source, _CACHE_READ_TOKENS_FIELD)
|
|
580
|
+
)
|
|
581
|
+
cache_write_delta = (
|
|
582
|
+
self._result_cache_write_tokens
|
|
583
|
+
if self._saw_result_usage
|
|
584
|
+
else _integer(source, _CACHE_WRITE_TOKENS_FIELD)
|
|
585
|
+
)
|
|
586
|
+
input_tokens = self._initial_usage.input_tokens + input_delta
|
|
587
|
+
output_tokens = self._initial_usage.output_tokens + output_delta
|
|
588
|
+
cache_read_tokens = self._initial_usage.cache_read_tokens + cache_read_delta
|
|
589
|
+
cache_write_tokens = self._initial_usage.cache_write_tokens + cache_write_delta
|
|
590
|
+
priced = self._latest_cost_basis == _LIST_COST_BASIS and self._latest_cost is not None
|
|
591
|
+
context_window = (
|
|
592
|
+
self._latest_model_usage.get(_CONTEXT_WINDOW_FIELD)
|
|
593
|
+
if self._latest_model_usage is not None
|
|
594
|
+
else None
|
|
595
|
+
)
|
|
596
|
+
context_tokens = _context_tokens(self._latest_assistant_usage)
|
|
597
|
+
context_pct = (
|
|
598
|
+
context_tokens / context_window * PERCENT
|
|
599
|
+
if context_tokens is not None
|
|
600
|
+
and isinstance(context_window, int | float)
|
|
601
|
+
and not isinstance(context_window, bool)
|
|
602
|
+
and context_window > 0
|
|
603
|
+
else None
|
|
604
|
+
)
|
|
605
|
+
return Usage(
|
|
606
|
+
input_tokens=input_tokens,
|
|
607
|
+
output_tokens=output_tokens,
|
|
608
|
+
cache_read_tokens=cache_read_tokens,
|
|
609
|
+
cache_write_tokens=cache_write_tokens,
|
|
610
|
+
cost_usd=self._latest_cost if priced else None,
|
|
611
|
+
cost_basis="api_equivalent" if priced else "unknown",
|
|
612
|
+
context_used_pct=context_pct,
|
|
613
|
+
)
|
|
614
|
+
|
|
615
|
+
def _fail(self, payload: Mapping[str, Any], stderr: str) -> list[AgentSignal]:
|
|
616
|
+
failure = self._classify_failure(payload, stderr)
|
|
617
|
+
if failure is None:
|
|
618
|
+
failure = (FailureKind.UNKNOWN, _UNKNOWN_ERROR_MESSAGE, None)
|
|
619
|
+
kind, message, resets_at = failure
|
|
620
|
+
if kind is FailureKind.QUOTA and resets_at is None:
|
|
621
|
+
resets_at = _latest_reset(self._latest_quota)
|
|
622
|
+
if kind is FailureKind.QUOTA and resets_at is None:
|
|
623
|
+
resets_at = self._reset_time.parse(message)
|
|
624
|
+
return [TurnFailed(kind, message, resets_at)]
|
|
625
|
+
|
|
626
|
+
def _classify_failure(
|
|
627
|
+
self, payload: Mapping[str, Any] | None, stderr: str
|
|
628
|
+
) -> tuple[FailureKind, str, datetime | None] | None:
|
|
629
|
+
status = _api_error_status(payload)
|
|
630
|
+
message = _error_message(payload, stderr)
|
|
631
|
+
lowered = f"{message} {stderr}".lower()
|
|
632
|
+
tag_match = _ERROR_TAG_PATTERN.search(stderr)
|
|
633
|
+
code = tag_match.group("code").lower() if tag_match is not None else ""
|
|
634
|
+
terminal_reason = payload.get(_TERMINAL_REASON_FIELD) if payload is not None else None
|
|
635
|
+
lowered = f"{lowered} {terminal_reason or ''} {code}"
|
|
636
|
+
classified = _classify_status(status, message, self._reset_time)
|
|
637
|
+
if status == STATUS_PAYMENT_REQUIRED:
|
|
638
|
+
return classified
|
|
639
|
+
if classified is None:
|
|
640
|
+
classified = _classify_text(lowered, message, stderr)
|
|
641
|
+
if classified is None and _contains(lowered, _CONTEXT_ERROR_TEXTS):
|
|
642
|
+
self._context_exceeded = True
|
|
643
|
+
classified = FailureKind.OTHER, message or stderr.strip(), None
|
|
644
|
+
if classified is None and (status is not None or message or stderr):
|
|
645
|
+
classified = FailureKind.UNKNOWN, message or stderr.strip(), None
|
|
646
|
+
if classified is not None and classified[0] is FailureKind.QUOTA:
|
|
647
|
+
kind, failure_message, resets_at = classified
|
|
648
|
+
return kind, failure_message, resets_at or self._reset_time.parse(message)
|
|
649
|
+
return classified
|
|
650
|
+
|
|
651
|
+
def _compaction_problem(self) -> str | None:
|
|
652
|
+
limit = self._compact_limit_tokens
|
|
653
|
+
if limit is None:
|
|
654
|
+
return None
|
|
655
|
+
if not self._did_compact:
|
|
656
|
+
return _INVALID_COMPACTION_MESSAGE
|
|
657
|
+
if self._malformed_compaction_metadata:
|
|
658
|
+
return _MALFORMED_COMPACTION_MESSAGE
|
|
659
|
+
post_tokens = self._post_compaction_tokens
|
|
660
|
+
if post_tokens is None or post_tokens >= limit:
|
|
661
|
+
return _INEFFECTIVE_COMPACTION_MESSAGE.format(post_tokens=post_tokens, limit=limit)
|
|
662
|
+
return None
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
class ClaudeTurnSequence(TurnProcessSequence):
|
|
666
|
+
"""Build one turn's prompt, compaction and continuation processes."""
|
|
667
|
+
|
|
668
|
+
def __init__(
|
|
669
|
+
self,
|
|
670
|
+
spec: TurnSpec,
|
|
671
|
+
*,
|
|
672
|
+
clock: Clock,
|
|
673
|
+
reset_time: ResetTimeParser,
|
|
674
|
+
compact_at_pct: float,
|
|
675
|
+
args: Sequence[str],
|
|
676
|
+
environment: Callable[[TurnSpec], Mapping[str, str]],
|
|
677
|
+
session_id: str | None = None,
|
|
678
|
+
) -> None:
|
|
679
|
+
self._clock = clock
|
|
680
|
+
self._reset_time = reset_time
|
|
681
|
+
self._limit = compact_limit_tokens(spec.rung.model, compact_at_pct)
|
|
682
|
+
self._args = tuple(args)
|
|
683
|
+
self._environment = environment
|
|
684
|
+
self._session_id = spec.session_id or session_id or str(uuid.uuid4())
|
|
685
|
+
self._session_exists = spec.session_id is not None
|
|
686
|
+
self._usage = spec.usage
|
|
687
|
+
self._marker = spec.turn_dir / f"{spec.role}{COMPACT_MARKER_SUFFIX}"
|
|
688
|
+
self._state = spec.turn_dir / f"{spec.role}{COMPACT_STATE_SUFFIX}"
|
|
689
|
+
self._then: str | None = None
|
|
690
|
+
self._recovered = False
|
|
691
|
+
self._parser: ClaudeTurnParser | None = None
|
|
692
|
+
|
|
693
|
+
@property
|
|
694
|
+
def session_id(self) -> str:
|
|
695
|
+
return self._session_id
|
|
696
|
+
|
|
697
|
+
def start(self, spec: TurnSpec) -> TurnProcess:
|
|
698
|
+
self._marker.unlink(missing_ok=True)
|
|
699
|
+
if self._session_exists and self._transcript_tokens(spec) >= self._limit:
|
|
700
|
+
return self._compact(spec, then=spec.prompt)
|
|
701
|
+
return self._process(spec, spec.prompt)
|
|
702
|
+
|
|
703
|
+
def advance(self, spec: TurnSpec, previous: TurnProcessResult) -> TurnProcess | None:
|
|
704
|
+
self._remember_usage(previous.signals)
|
|
705
|
+
if self._then is not None:
|
|
706
|
+
prompt, self._then = self._then, None
|
|
707
|
+
compacted = isinstance(_terminal(previous.signals), TurnEnded)
|
|
708
|
+
return self._process(spec, prompt) if compacted else None
|
|
709
|
+
if self._marker.exists():
|
|
710
|
+
self._marker.unlink()
|
|
711
|
+
return self._compact(spec, then=CONTINUE_PROMPT)
|
|
712
|
+
if self._parser is not None and self._parser.context_exceeded and not self._recovered:
|
|
713
|
+
self._recovered = True
|
|
714
|
+
return self._compact(spec, then=spec.prompt)
|
|
715
|
+
return None
|
|
716
|
+
|
|
717
|
+
def _compact(self, spec: TurnSpec, *, then: str) -> TurnProcess:
|
|
718
|
+
self._then = then
|
|
719
|
+
return self._process(spec, _COMPACT_PROMPT, compact=True)
|
|
720
|
+
|
|
721
|
+
def _process(self, spec: TurnSpec, prompt: str, *, compact: bool = False) -> TurnProcess:
|
|
722
|
+
session_flag = RESUME_FLAG if self._session_exists else SESSION_ID_FLAG
|
|
723
|
+
self._session_exists = True
|
|
724
|
+
argv: list[str] = [CLAUDE_COMMAND, PRINT_FLAG, prompt, session_flag, self._session_id]
|
|
725
|
+
argv.extend([OUTPUT_FORMAT_FLAG, _STREAM_JSON, _VERBOSE_FLAG, MODEL_FLAG, spec.rung.model])
|
|
726
|
+
if spec.rung.effort is not None:
|
|
727
|
+
argv.extend([EFFORT_FLAG, spec.rung.effort])
|
|
728
|
+
for directory in _unique_paths((spec.run_dir, *spec.add_dirs)):
|
|
729
|
+
argv.extend([ADD_DIR_FLAG, str(directory)])
|
|
730
|
+
argv.extend([SETTINGS_FLAG, self._hook_settings()])
|
|
731
|
+
argv.extend(self._args)
|
|
732
|
+
self._parser = ClaudeTurnParser(
|
|
733
|
+
session_id=self._session_id,
|
|
734
|
+
model=spec.rung.model,
|
|
735
|
+
initial_usage=self._usage,
|
|
736
|
+
clock=self._clock,
|
|
737
|
+
reset_time=self._reset_time,
|
|
738
|
+
compact_limit_tokens=self._limit if compact else None,
|
|
739
|
+
)
|
|
740
|
+
return TurnProcess(tuple(argv), spec.cwd, self._environment(spec), self._parser)
|
|
741
|
+
|
|
742
|
+
def _hook_settings(self) -> str:
|
|
743
|
+
command = module_command(HOOK_MODULE, str(self._state), str(self._marker), str(self._limit))
|
|
744
|
+
hook = {_HOOK_TYPE_FIELD: _COMMAND_HOOK_TYPE, _HOOK_COMMAND_FIELD: command}
|
|
745
|
+
matcher = {_HOOK_MATCHER_FIELD: _ANY_TOOL_MATCHER, _HOOKS_FIELD: [hook]}
|
|
746
|
+
return json.dumps({_HOOKS_FIELD: {_POST_TOOL_USE_EVENT: [matcher]}})
|
|
747
|
+
|
|
748
|
+
def _transcript_tokens(self, spec: TurnSpec) -> int:
|
|
749
|
+
environment = self._environment(spec)
|
|
750
|
+
config_dir = environment.get(CLAUDE_CONFIG_DIR_ENV_VAR)
|
|
751
|
+
root = Path(config_dir) if config_dir else Path(environment[HOME_ENV_VAR]) / CLAUDE_DIRNAME
|
|
752
|
+
transcripts = sorted(
|
|
753
|
+
(root / PROJECTS_DIRNAME).glob(f"*/{self._session_id}{TRANSCRIPT_SUFFIX}"),
|
|
754
|
+
key=lambda path: path.stat().st_mtime_ns,
|
|
755
|
+
)
|
|
756
|
+
if not transcripts:
|
|
757
|
+
return 0
|
|
758
|
+
return transcript_context_tokens(read_transcript(transcripts[-1]))
|
|
759
|
+
|
|
760
|
+
def _remember_usage(self, signals: Sequence[AgentSignal]) -> None:
|
|
761
|
+
usages = [signal.usage for signal in signals if isinstance(signal, UsageReported)]
|
|
762
|
+
if usages:
|
|
763
|
+
self._usage = usages[-1]
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def _empty_usage() -> Usage:
|
|
767
|
+
return Usage(0, 0, 0, 0, None, "unknown", None)
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def _mapping(value: object) -> Mapping[str, Any] | None:
|
|
771
|
+
return cast("Mapping[str, Any]", value) if isinstance(value, Mapping) else None
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
def _optional_nonnegative_integer(payload: Mapping[str, Any] | None, key: str) -> int | None:
|
|
775
|
+
if payload is None:
|
|
776
|
+
return None
|
|
777
|
+
value = payload.get(key)
|
|
778
|
+
return value if type(value) is int and value >= 0 else None
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
def _tool_detail(value: object) -> str:
|
|
782
|
+
if isinstance(value, str):
|
|
783
|
+
detail = value
|
|
784
|
+
elif isinstance(value, Mapping):
|
|
785
|
+
detail = json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
|
786
|
+
else:
|
|
787
|
+
detail = ""
|
|
788
|
+
return detail[:_TOOL_DETAIL_MAX_CHARS]
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
def _is_error_result(payload: Mapping[str, Any]) -> bool:
|
|
792
|
+
if payload.get(_IS_ERROR_FIELD) is True:
|
|
793
|
+
return True
|
|
794
|
+
if _api_error_status(payload) is not None:
|
|
795
|
+
return True
|
|
796
|
+
terminal_reason = payload.get(_TERMINAL_REASON_FIELD)
|
|
797
|
+
if isinstance(terminal_reason, str) and terminal_reason.lower() in _ERROR_TERMINAL_REASONS:
|
|
798
|
+
return True
|
|
799
|
+
return payload.get(_SUBTYPE_FIELD) not in (None, _SUCCESS_SUBTYPE) and isinstance(
|
|
800
|
+
payload.get(_SUBTYPE_FIELD), str
|
|
801
|
+
)
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
def _is_no_work_task_notification_result(payload: Mapping[str, Any], model: str) -> bool:
|
|
805
|
+
origin = _mapping(payload.get(_ORIGIN_FIELD))
|
|
806
|
+
usage = _mapping(payload.get(_USAGE_FIELD))
|
|
807
|
+
if (
|
|
808
|
+
origin is None
|
|
809
|
+
or origin.get(_ORIGIN_KIND_FIELD) != _TASK_NOTIFICATION_ORIGIN_KIND
|
|
810
|
+
or payload.get(_RESULT_FIELD) != _EMPTY_RESULT
|
|
811
|
+
or type(payload.get(_RESULT_INDEX_FIELD)) is not int
|
|
812
|
+
or payload.get(_RESULT_INDEX_FIELD) != 0
|
|
813
|
+
or payload.get(_STOP_REASON_FIELD) is not None
|
|
814
|
+
or payload.get(_TERMINAL_REASON_FIELD) is not None
|
|
815
|
+
or usage is None
|
|
816
|
+
):
|
|
817
|
+
return False
|
|
818
|
+
zero_usage = all(
|
|
819
|
+
key in usage
|
|
820
|
+
and isinstance(usage[key], int | float)
|
|
821
|
+
and not isinstance(usage[key], bool)
|
|
822
|
+
and usage[key] == 0
|
|
823
|
+
for key in (
|
|
824
|
+
_INPUT_TOKENS_FIELD,
|
|
825
|
+
_OUTPUT_TOKENS_FIELD,
|
|
826
|
+
_CACHE_READ_TOKENS_FIELD,
|
|
827
|
+
_CACHE_WRITE_TOKENS_FIELD,
|
|
828
|
+
)
|
|
829
|
+
)
|
|
830
|
+
if not zero_usage:
|
|
831
|
+
return False
|
|
832
|
+
delta = _mapping(payload.get(_MODEL_USAGE_DELTA_FIELD))
|
|
833
|
+
return delta is None or not _has_model_usage_delta(delta, model)
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
def _has_model_usage_delta(delta: Mapping[str, Any], model: str) -> bool:
|
|
837
|
+
selected = _select_model_usage(delta, model)
|
|
838
|
+
if selected is None:
|
|
839
|
+
return False
|
|
840
|
+
return any(
|
|
841
|
+
isinstance(selected.get(field), int | float)
|
|
842
|
+
and not isinstance(selected.get(field), bool)
|
|
843
|
+
and selected[field] != 0
|
|
844
|
+
for field in (
|
|
845
|
+
_INPUT_TOKENS_DELTA_FIELD,
|
|
846
|
+
_OUTPUT_TOKENS_DELTA_FIELD,
|
|
847
|
+
_CACHE_READ_TOKENS_DELTA_FIELD,
|
|
848
|
+
_CACHE_WRITE_TOKENS_DELTA_FIELD,
|
|
849
|
+
_COST_USD_FIELD,
|
|
850
|
+
)
|
|
851
|
+
)
|
|
852
|
+
|
|
853
|
+
|
|
854
|
+
def _result_usage_delta(payload: Mapping[str, Any], model: str) -> Mapping[str, Any] | None:
|
|
855
|
+
raw_delta = _mapping(payload.get(_MODEL_USAGE_DELTA_FIELD))
|
|
856
|
+
selected_delta = _select_model_usage(raw_delta, model)
|
|
857
|
+
if selected_delta is not None:
|
|
858
|
+
translated = {
|
|
859
|
+
_INPUT_TOKENS_FIELD: selected_delta.get(_INPUT_TOKENS_DELTA_FIELD),
|
|
860
|
+
_OUTPUT_TOKENS_FIELD: selected_delta.get(_OUTPUT_TOKENS_DELTA_FIELD),
|
|
861
|
+
_CACHE_READ_TOKENS_FIELD: selected_delta.get(_CACHE_READ_TOKENS_DELTA_FIELD),
|
|
862
|
+
_CACHE_WRITE_TOKENS_FIELD: selected_delta.get(_CACHE_WRITE_TOKENS_DELTA_FIELD),
|
|
863
|
+
}
|
|
864
|
+
if any(value is not None for value in translated.values()):
|
|
865
|
+
return translated
|
|
866
|
+
usage = _mapping(payload.get(_USAGE_FIELD))
|
|
867
|
+
token_fields = (
|
|
868
|
+
_INPUT_TOKENS_FIELD,
|
|
869
|
+
_OUTPUT_TOKENS_FIELD,
|
|
870
|
+
_CACHE_READ_TOKENS_FIELD,
|
|
871
|
+
_CACHE_WRITE_TOKENS_FIELD,
|
|
872
|
+
)
|
|
873
|
+
if usage is None or not any(field in usage for field in token_fields):
|
|
874
|
+
return None
|
|
875
|
+
return usage
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
def _api_error_status(payload: Mapping[str, Any] | None) -> int | None:
|
|
879
|
+
if payload is None:
|
|
880
|
+
return None
|
|
881
|
+
value = payload.get(_API_ERROR_STATUS_FIELD)
|
|
882
|
+
if (isinstance(value, int) and value in _ERROR_STATUSES) or (
|
|
883
|
+
isinstance(value, int) and _UNAVAILABLE_STATUS_MIN <= value <= _UNAVAILABLE_STATUS_MAX
|
|
884
|
+
):
|
|
885
|
+
return value
|
|
886
|
+
error = _mapping(payload.get(_ERROR_FIELD))
|
|
887
|
+
nested = error.get(_API_ERROR_STATUS_FIELD, error.get(_STATUS_FIELD)) if error else None
|
|
888
|
+
return nested if isinstance(nested, int) else None
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
def _error_message(payload: Mapping[str, Any] | None, stderr: str) -> str:
|
|
892
|
+
if payload is None:
|
|
893
|
+
tag = _ERROR_TAG_PATTERN.search(stderr)
|
|
894
|
+
return tag.group("code") if tag is not None else stderr.strip()
|
|
895
|
+
parts: list[str] = []
|
|
896
|
+
for key in (_RESULT_FIELD, _ERROR_FIELD):
|
|
897
|
+
value = payload.get(key)
|
|
898
|
+
if isinstance(value, str):
|
|
899
|
+
parts.append(value)
|
|
900
|
+
elif isinstance(value, Mapping):
|
|
901
|
+
typed_value = cast("Mapping[str, object]", value)
|
|
902
|
+
nested_message = typed_value.get(_MESSAGE_TEXT_FIELD)
|
|
903
|
+
if isinstance(nested_message, str):
|
|
904
|
+
parts.append(nested_message)
|
|
905
|
+
errors = payload.get(_ERRORS_FIELD)
|
|
906
|
+
if isinstance(errors, list):
|
|
907
|
+
parts.extend(
|
|
908
|
+
error
|
|
909
|
+
for item in cast("list[object]", errors)
|
|
910
|
+
if isinstance((error := _error_message_from_item(item)), str)
|
|
911
|
+
)
|
|
912
|
+
tag = _ERROR_TAG_PATTERN.search(stderr)
|
|
913
|
+
if tag is not None:
|
|
914
|
+
parts.append(tag.group("code"))
|
|
915
|
+
return "; ".join(part for part in parts if part)
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
def _error_message_from_item(value: object) -> str | None:
|
|
919
|
+
if isinstance(value, str):
|
|
920
|
+
return value
|
|
921
|
+
payload = _mapping(value)
|
|
922
|
+
if payload is None:
|
|
923
|
+
return None
|
|
924
|
+
message = payload.get(_MESSAGE_TEXT_FIELD)
|
|
925
|
+
return message if isinstance(message, str) else None
|
|
926
|
+
|
|
927
|
+
|
|
928
|
+
def _contains(value: str, fragments: Sequence[str]) -> bool:
|
|
929
|
+
return any(fragment in value for fragment in fragments)
|
|
930
|
+
|
|
931
|
+
|
|
932
|
+
def _classify_status(
|
|
933
|
+
status: int | None, message: str, reset_time: ResetTimeParser
|
|
934
|
+
) -> tuple[FailureKind, str, datetime | None] | None:
|
|
935
|
+
detail = message or _STATUS_MESSAGE.format(status=status)
|
|
936
|
+
if status == STATUS_PAYMENT_REQUIRED:
|
|
937
|
+
return FailureKind.AUTH, detail, None
|
|
938
|
+
if status == STATUS_TOO_MANY_REQUESTS:
|
|
939
|
+
return FailureKind.QUOTA, detail, reset_time.parse(message)
|
|
940
|
+
if isinstance(status, int) and _UNAVAILABLE_STATUS_MIN <= status <= _UNAVAILABLE_STATUS_MAX:
|
|
941
|
+
return FailureKind.UNAVAILABLE, detail, None
|
|
942
|
+
if status in (STATUS_UNAUTHORIZED, STATUS_FORBIDDEN):
|
|
943
|
+
return FailureKind.AUTH, detail, None
|
|
944
|
+
return None
|
|
945
|
+
|
|
946
|
+
|
|
947
|
+
def _classify_text(
|
|
948
|
+
lowered: str, message: str, stderr: str
|
|
949
|
+
) -> tuple[FailureKind, str, datetime | None] | None:
|
|
950
|
+
if _contains(lowered, _QUOTA_ERROR_TEXTS):
|
|
951
|
+
return FailureKind.QUOTA, message or stderr.strip(), None
|
|
952
|
+
if _contains(lowered, _UNAVAILABLE_ERROR_TEXTS):
|
|
953
|
+
return FailureKind.UNAVAILABLE, message or stderr.strip(), None
|
|
954
|
+
if _contains(lowered, _AUTH_ERROR_TEXTS):
|
|
955
|
+
return FailureKind.AUTH, message or stderr.strip(), None
|
|
956
|
+
return None
|
|
957
|
+
|
|
958
|
+
|
|
959
|
+
def _stderr_message(exit_code: int, stderr: str, message: str) -> str:
|
|
960
|
+
detail = message.strip() or stderr.strip() or EMPTY_OUTPUT
|
|
961
|
+
return _STDERR_MESSAGE.format(exit_code=exit_code, message=detail)
|
|
962
|
+
|
|
963
|
+
|
|
964
|
+
def _integer(payload: Mapping[str, Any] | None, key: str) -> int:
|
|
965
|
+
value = payload.get(key, 0) if payload is not None else 0
|
|
966
|
+
return int(value) if isinstance(value, int | float) and not isinstance(value, bool) else 0
|
|
967
|
+
|
|
968
|
+
|
|
969
|
+
def _context_tokens(usage: Mapping[str, Any] | None) -> int | None:
|
|
970
|
+
if usage is None:
|
|
971
|
+
return None
|
|
972
|
+
token_counts = (
|
|
973
|
+
usage.get(_INPUT_TOKENS_FIELD),
|
|
974
|
+
usage.get(_CACHE_READ_TOKENS_FIELD),
|
|
975
|
+
usage.get(_CACHE_WRITE_TOKENS_FIELD),
|
|
976
|
+
)
|
|
977
|
+
complete_counts: list[int] = []
|
|
978
|
+
for token_count in token_counts:
|
|
979
|
+
if type(token_count) is not int or token_count < 0:
|
|
980
|
+
return None
|
|
981
|
+
complete_counts.append(token_count)
|
|
982
|
+
return sum(complete_counts)
|
|
983
|
+
|
|
984
|
+
|
|
985
|
+
def _select_model_usage(
|
|
986
|
+
model_usage: Mapping[str, Any] | None, model: str
|
|
987
|
+
) -> Mapping[str, Any] | None:
|
|
988
|
+
if model_usage is None:
|
|
989
|
+
return None
|
|
990
|
+
exact = _mapping(model_usage.get(model))
|
|
991
|
+
if exact is not None:
|
|
992
|
+
return exact
|
|
993
|
+
if len(model_usage) == 1:
|
|
994
|
+
return _mapping(next(iter(model_usage.values())))
|
|
995
|
+
return None
|
|
996
|
+
|
|
997
|
+
|
|
998
|
+
def _latest_reset(windows: Sequence[QuotaWindow]) -> datetime | None:
|
|
999
|
+
resets = [window.resets_at for window in windows if window.resets_at is not None]
|
|
1000
|
+
return min(resets) if resets else None
|
|
1001
|
+
|
|
1002
|
+
|
|
1003
|
+
def _terminal(signals: Sequence[AgentSignal]) -> TurnEnded | TurnFailed | None:
|
|
1004
|
+
return next(
|
|
1005
|
+
(signal for signal in reversed(signals) if isinstance(signal, (TurnEnded, TurnFailed))),
|
|
1006
|
+
None,
|
|
1007
|
+
)
|
|
1008
|
+
|
|
1009
|
+
|
|
1010
|
+
def _unique_paths(paths: Sequence[Path]) -> tuple[Path, ...]:
|
|
1011
|
+
unique: list[Path] = []
|
|
1012
|
+
seen: set[Path] = set()
|
|
1013
|
+
for path in paths:
|
|
1014
|
+
normalized = path.resolve()
|
|
1015
|
+
if normalized not in seen:
|
|
1016
|
+
seen.add(normalized)
|
|
1017
|
+
unique.append(path)
|
|
1018
|
+
return tuple(unique)
|