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,767 @@
|
|
|
1
|
+
"""Codex JSONL turn parser and process sequence."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from typing import TYPE_CHECKING, Any, NamedTuple, cast
|
|
9
|
+
|
|
10
|
+
from shikiru.agents.contracts import (
|
|
11
|
+
AgentSignal,
|
|
12
|
+
Compacted,
|
|
13
|
+
FailureKind,
|
|
14
|
+
Message,
|
|
15
|
+
QuotaReported,
|
|
16
|
+
QuotaWindow,
|
|
17
|
+
SessionStarted,
|
|
18
|
+
ToolFinished,
|
|
19
|
+
ToolOutcome,
|
|
20
|
+
ToolStarted,
|
|
21
|
+
TurnEnded,
|
|
22
|
+
TurnFailed,
|
|
23
|
+
TurnSpec,
|
|
24
|
+
Usage,
|
|
25
|
+
UsageReported,
|
|
26
|
+
exhausted_reset,
|
|
27
|
+
)
|
|
28
|
+
from shikiru.agents.sdk.turn import (
|
|
29
|
+
EMPTY_OUTPUT,
|
|
30
|
+
TurnProcess,
|
|
31
|
+
TurnProcessResult,
|
|
32
|
+
TurnProcessSequence,
|
|
33
|
+
)
|
|
34
|
+
from shikiru.plugins.agents.codex.home import codex_home
|
|
35
|
+
from shikiru.plugins.agents.codex.options import (
|
|
36
|
+
AUTO_COMPACT_LIMIT_CONFIG,
|
|
37
|
+
CODEX_COMMAND,
|
|
38
|
+
CONFIG_FLAG,
|
|
39
|
+
EXEC_SUBCOMMAND,
|
|
40
|
+
JSON_FLAG,
|
|
41
|
+
MODEL_FLAG,
|
|
42
|
+
MODELS_CACHE_FILENAME,
|
|
43
|
+
REASONING_EFFORT_CONFIG,
|
|
44
|
+
RESUME_SUBCOMMAND,
|
|
45
|
+
USAGE_EVENT_TYPE,
|
|
46
|
+
)
|
|
47
|
+
from shikiru.plugins.agents.codex.quota import CodexQuotaProbe, QuotaObservation
|
|
48
|
+
from shikiru.plugins.agents.codex.sandbox import sandbox_config_args
|
|
49
|
+
from shikiru.plugins.agents.codex.usage import latest_token_count, rollout_usage
|
|
50
|
+
|
|
51
|
+
if TYPE_CHECKING:
|
|
52
|
+
from collections.abc import Callable, Sequence
|
|
53
|
+
from pathlib import Path
|
|
54
|
+
|
|
55
|
+
from shikiru.agents.sdk.reset_time import ResetTimeParser
|
|
56
|
+
from shikiru.clock import Clock
|
|
57
|
+
|
|
58
|
+
_TYPE_FIELD = "type"
|
|
59
|
+
_PHASE_START = "start"
|
|
60
|
+
_PHASE_PROMPT = "prompt"
|
|
61
|
+
_PHASE_RETRY = "retry"
|
|
62
|
+
_THREAD_STARTED = "thread.started"
|
|
63
|
+
_THREAD_ID_FIELD = "thread_id"
|
|
64
|
+
_ITEM_STARTED = "item.started"
|
|
65
|
+
_ITEM_COMPLETED = "item.completed"
|
|
66
|
+
_ITEM_FIELD = "item"
|
|
67
|
+
_ITEM_ID_FIELD = "id"
|
|
68
|
+
_ITEM_TYPE_FIELD = "type"
|
|
69
|
+
_ITEM_STATUS_FIELD = "status"
|
|
70
|
+
_EXIT_CODE_FIELD = "exit_code"
|
|
71
|
+
_FAILED_STATUS = "failed"
|
|
72
|
+
_AGENT_MESSAGE_TYPE = "agent_message"
|
|
73
|
+
_MESSAGE_TEXT_FIELD = "text"
|
|
74
|
+
_TURN_COMPLETED = "turn.completed"
|
|
75
|
+
_TURN_FAILED = "turn.failed"
|
|
76
|
+
_ERROR_TYPE = "error"
|
|
77
|
+
_ERROR_FIELD = "error"
|
|
78
|
+
_PAYLOAD_FIELD = "payload"
|
|
79
|
+
_USAGE_FIELD = "usage"
|
|
80
|
+
_INFO_FIELD = "info"
|
|
81
|
+
_INPUT_TOKENS_FIELD = "input_tokens"
|
|
82
|
+
_CACHED_INPUT_TOKENS_FIELD = "cached_input_tokens"
|
|
83
|
+
_OUTPUT_TOKENS_FIELD = "output_tokens"
|
|
84
|
+
_LAST_USAGE_FIELD = "last_token_usage"
|
|
85
|
+
_STATUS_FIELD = "status"
|
|
86
|
+
_CODE_FIELD = "code"
|
|
87
|
+
_MESSAGE_FIELD = "message"
|
|
88
|
+
_RESET_AT_FIELD = "resets_at"
|
|
89
|
+
_EVENT_MESSAGE_TYPE = "event_msg"
|
|
90
|
+
_COMPACTED_TYPE = "compacted"
|
|
91
|
+
_PRE_TOKENS_FIELD = "pre_tokens"
|
|
92
|
+
_POST_TOKENS_FIELD = "post_tokens"
|
|
93
|
+
_MODELS_FIELD = "models"
|
|
94
|
+
_MODEL_SLUG_FIELD = "slug"
|
|
95
|
+
_MODEL_CONTEXT_WINDOW_FIELD = "context_window"
|
|
96
|
+
_TURN_CONTEXT_TYPE = "turn_context"
|
|
97
|
+
_MODEL_FIELD = "model"
|
|
98
|
+
_CONTEXT_WINDOW_INFO_FIELD = "model_context_window"
|
|
99
|
+
_PERCENT = 100
|
|
100
|
+
_UNAVAILABLE_STATUS_MIN = 500
|
|
101
|
+
_UNAVAILABLE_STATUS_MAX = 599
|
|
102
|
+
_PAYMENT_REQUIRED_STATUS = 402
|
|
103
|
+
_RATE_LIMIT_STATUS = 429
|
|
104
|
+
_AUTH_STATUSES = (401, 403)
|
|
105
|
+
_CAPACITY_TEXT = ("at capacity",)
|
|
106
|
+
_UNAVAILABLE_TEXT = ("overloaded", "temporarily unavailable", "service unavailable")
|
|
107
|
+
_CONTEXT_TEXT = (
|
|
108
|
+
"context_length_exceeded",
|
|
109
|
+
"context exceeded",
|
|
110
|
+
"context length",
|
|
111
|
+
"context window",
|
|
112
|
+
"too many tokens",
|
|
113
|
+
)
|
|
114
|
+
_CONTEXT_LENGTH_EXCEEDED_CODE = "context_length_exceeded"
|
|
115
|
+
_QUOTA_TEXT = ("usage_limit_reached", "usage limit", "rate_limit", "rate limit", "quota")
|
|
116
|
+
_AUTH_TEXT = ("authentication", "unauthorized", "invalid api key")
|
|
117
|
+
_UNKNOWN_ERROR_MESSAGE = "Codex turn ended without a structured result"
|
|
118
|
+
_MALFORMED_STREAM_MESSAGE = "Codex emitted malformed JSONL"
|
|
119
|
+
_ROLLOUT_GLOB = "rollout-*.jsonl"
|
|
120
|
+
_COMMAND_EXECUTION_TYPE = "command_execution"
|
|
121
|
+
_FILE_CHANGE_TYPE = "file_change"
|
|
122
|
+
_MCP_TOOL_CALL_TYPE = "mcp_tool_call"
|
|
123
|
+
_COMMAND_TOOL_NAME = "command"
|
|
124
|
+
_FILE_CHANGE_TOOL_NAME = "file_change"
|
|
125
|
+
_MCP_TOOL_NAME = "mcp_tool"
|
|
126
|
+
_ZERO_TOKENS = 0
|
|
127
|
+
_ZERO_EXIT_CODE = 0
|
|
128
|
+
_UNKNOWN_COST = "unknown"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class CodexTurnParser:
|
|
132
|
+
"""Map Codex JSONL events and its rollout into agent signals."""
|
|
133
|
+
|
|
134
|
+
def __init__(
|
|
135
|
+
self,
|
|
136
|
+
*,
|
|
137
|
+
session_id: str | None,
|
|
138
|
+
initial_usage: Usage | None,
|
|
139
|
+
home: Path,
|
|
140
|
+
rollout_path: Path | None = None,
|
|
141
|
+
clock: Clock,
|
|
142
|
+
reset_time: ResetTimeParser,
|
|
143
|
+
) -> None:
|
|
144
|
+
self._session_id = session_id
|
|
145
|
+
self._initial_usage = initial_usage or Usage(
|
|
146
|
+
_ZERO_TOKENS,
|
|
147
|
+
_ZERO_TOKENS,
|
|
148
|
+
_ZERO_TOKENS,
|
|
149
|
+
_ZERO_TOKENS,
|
|
150
|
+
None,
|
|
151
|
+
_UNKNOWN_COST,
|
|
152
|
+
None,
|
|
153
|
+
)
|
|
154
|
+
self._home = home
|
|
155
|
+
self._rollout_override = rollout_path
|
|
156
|
+
self._clock = clock
|
|
157
|
+
self._reset_time = reset_time
|
|
158
|
+
self._quota_probe = CodexQuotaProbe(home, clock, reset_time)
|
|
159
|
+
self._latest_streamed_quota: tuple[QuotaWindow, ...] = ()
|
|
160
|
+
self._tools: dict[str, str] = {}
|
|
161
|
+
self._last_message = ""
|
|
162
|
+
self._turn_usage: Mapping[str, Any] | None = None
|
|
163
|
+
self._failure: Mapping[str, Any] | None = None
|
|
164
|
+
self._malformed = False
|
|
165
|
+
self._terminal = False
|
|
166
|
+
self._did_start_thread = False
|
|
167
|
+
self._did_complete = False
|
|
168
|
+
self._context_exceeded = False
|
|
169
|
+
self._rollout = rollout_path or _rollout_path(home, session_id)
|
|
170
|
+
self._initial_compaction_count = (
|
|
171
|
+
_compaction_count(_read_rollout(self._rollout)) if session_id is not None else 0
|
|
172
|
+
)
|
|
173
|
+
self._handlers = {
|
|
174
|
+
_THREAD_STARTED: self._thread_started,
|
|
175
|
+
_ITEM_STARTED: self._item_started,
|
|
176
|
+
_ITEM_COMPLETED: self._item_completed,
|
|
177
|
+
_TURN_COMPLETED: self._turn_completed,
|
|
178
|
+
_TURN_FAILED: self._record_failure,
|
|
179
|
+
_ERROR_TYPE: self._record_failure,
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
@property
|
|
183
|
+
def context_exceeded(self) -> bool:
|
|
184
|
+
"""Return whether this process failed because its context was full."""
|
|
185
|
+
return self._context_exceeded
|
|
186
|
+
|
|
187
|
+
def feed(self, line: str) -> list[AgentSignal]:
|
|
188
|
+
if self._terminal:
|
|
189
|
+
return []
|
|
190
|
+
try:
|
|
191
|
+
raw = json.loads(line)
|
|
192
|
+
except json.JSONDecodeError:
|
|
193
|
+
self._malformed = True
|
|
194
|
+
return []
|
|
195
|
+
if not isinstance(raw, Mapping):
|
|
196
|
+
self._malformed = True
|
|
197
|
+
return []
|
|
198
|
+
payload = cast("Mapping[str, Any]", raw)
|
|
199
|
+
handler = self._handlers.get(cast("str", payload.get(_TYPE_FIELD)))
|
|
200
|
+
signals = handler(payload) if handler is not None else []
|
|
201
|
+
observation = self._quota_probe.parse_observation(line, self._clock.now())
|
|
202
|
+
if observation is not None:
|
|
203
|
+
self._latest_streamed_quota = observation.windows
|
|
204
|
+
signals.append(QuotaReported(observation.windows))
|
|
205
|
+
return signals
|
|
206
|
+
|
|
207
|
+
def _turn_completed(self, payload: Mapping[str, Any]) -> list[AgentSignal]:
|
|
208
|
+
self._did_complete = True
|
|
209
|
+
self._turn_usage = _mapping(payload.get(_USAGE_FIELD))
|
|
210
|
+
message = payload.get(_MESSAGE_TEXT_FIELD)
|
|
211
|
+
if isinstance(message, str) and message:
|
|
212
|
+
self._last_message = message
|
|
213
|
+
return []
|
|
214
|
+
|
|
215
|
+
def _record_failure(self, payload: Mapping[str, Any]) -> list[AgentSignal]:
|
|
216
|
+
error = payload.get(_ERROR_FIELD)
|
|
217
|
+
self._failure = _mapping(error) or (
|
|
218
|
+
{_MESSAGE_FIELD: error} if isinstance(error, str) else payload
|
|
219
|
+
)
|
|
220
|
+
return []
|
|
221
|
+
|
|
222
|
+
def finish(
|
|
223
|
+
self, exit_code: int, stderr: str, *, interrupted: bool = False
|
|
224
|
+
) -> list[AgentSignal]:
|
|
225
|
+
if self._terminal:
|
|
226
|
+
return []
|
|
227
|
+
self._terminal = True
|
|
228
|
+
if self._malformed:
|
|
229
|
+
return [TurnFailed(FailureKind.UNKNOWN, _MALFORMED_STREAM_MESSAGE, None)]
|
|
230
|
+
if interrupted and not self._did_start_thread:
|
|
231
|
+
return self._finish_interrupted_before_session(exit_code, stderr)
|
|
232
|
+
if self._rollout is None:
|
|
233
|
+
self._rollout = _rollout_path(self._home, self._session_id)
|
|
234
|
+
entries = _read_rollout(self._rollout)
|
|
235
|
+
signals: list[AgentSignal] = list(
|
|
236
|
+
_compaction_signals(entries, self._initial_compaction_count)
|
|
237
|
+
)
|
|
238
|
+
quota: Sequence[QuotaWindow] = self._latest_streamed_quota
|
|
239
|
+
rollout_quota = _quota_observation(
|
|
240
|
+
self._rollout, entries, self._home, self._clock, self._reset_time
|
|
241
|
+
)
|
|
242
|
+
if rollout_quota is not None and _quota_is_newer(rollout_quota, quota):
|
|
243
|
+
rollout_reading = rollout_quota.windows
|
|
244
|
+
if _quota_reading(rollout_reading) != _quota_reading(quota):
|
|
245
|
+
signals.append(QuotaReported(rollout_reading))
|
|
246
|
+
quota = rollout_reading
|
|
247
|
+
usage = _reported_usage(entries, self._turn_usage, self._initial_usage)
|
|
248
|
+
if usage is not None:
|
|
249
|
+
signals.append(UsageReported(usage))
|
|
250
|
+
if self._failure is not None:
|
|
251
|
+
signals.append(self._failed_signal(self._failure, stderr, quota))
|
|
252
|
+
elif self._did_complete:
|
|
253
|
+
signals.append(TurnEnded(self._last_message))
|
|
254
|
+
else:
|
|
255
|
+
signals.extend(
|
|
256
|
+
self._finish_without_result(exit_code, stderr, quota, interrupted=interrupted)
|
|
257
|
+
)
|
|
258
|
+
return signals
|
|
259
|
+
|
|
260
|
+
def _finish_interrupted_before_session(self, exit_code: int, stderr: str) -> list[AgentSignal]:
|
|
261
|
+
if self._failure is not None:
|
|
262
|
+
return [self._failed_signal(self._failure, stderr, self._latest_streamed_quota)]
|
|
263
|
+
if self._did_complete:
|
|
264
|
+
return [TurnEnded(self._last_message)]
|
|
265
|
+
return self._finish_without_result(
|
|
266
|
+
exit_code, stderr, self._latest_streamed_quota, interrupted=True
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
def _finish_without_result(
|
|
270
|
+
self,
|
|
271
|
+
exit_code: int,
|
|
272
|
+
stderr: str,
|
|
273
|
+
quota: Sequence[QuotaWindow],
|
|
274
|
+
*,
|
|
275
|
+
interrupted: bool,
|
|
276
|
+
) -> list[AgentSignal]:
|
|
277
|
+
failure = _classify_error(stderr, None, "")
|
|
278
|
+
if failure is None:
|
|
279
|
+
if interrupted:
|
|
280
|
+
return []
|
|
281
|
+
return [
|
|
282
|
+
TurnFailed(
|
|
283
|
+
FailureKind.UNKNOWN,
|
|
284
|
+
f"{_UNKNOWN_ERROR_MESSAGE}; process exited with status {exit_code}; "
|
|
285
|
+
f"stderr tail: {stderr or EMPTY_OUTPUT}",
|
|
286
|
+
None,
|
|
287
|
+
)
|
|
288
|
+
]
|
|
289
|
+
self._context_exceeded = failure.context_exceeded
|
|
290
|
+
reset = (
|
|
291
|
+
self._reset_time.parse(stderr) or exhausted_reset(quota, self._clock.now())
|
|
292
|
+
if failure.kind is FailureKind.QUOTA
|
|
293
|
+
else None
|
|
294
|
+
)
|
|
295
|
+
return [TurnFailed(failure.kind, stderr.strip(), reset)]
|
|
296
|
+
|
|
297
|
+
def _thread_started(self, payload: Mapping[str, Any]) -> list[AgentSignal]:
|
|
298
|
+
session_id = payload.get(_THREAD_ID_FIELD)
|
|
299
|
+
if not isinstance(session_id, str) or not session_id.strip():
|
|
300
|
+
return []
|
|
301
|
+
self._did_start_thread = True
|
|
302
|
+
self._session_id = session_id
|
|
303
|
+
self._rollout = self._rollout_override or _rollout_path(self._home, session_id)
|
|
304
|
+
return [SessionStarted(session_id)]
|
|
305
|
+
|
|
306
|
+
def _item_started(self, payload: Mapping[str, Any]) -> list[AgentSignal]:
|
|
307
|
+
item = _mapping(payload.get(_ITEM_FIELD))
|
|
308
|
+
if item is None:
|
|
309
|
+
return []
|
|
310
|
+
item_id = item.get(_ITEM_ID_FIELD)
|
|
311
|
+
item_type = item.get(_ITEM_TYPE_FIELD)
|
|
312
|
+
if not isinstance(item_id, str) or not isinstance(item_type, str):
|
|
313
|
+
return []
|
|
314
|
+
name = _tool_name(item_type)
|
|
315
|
+
if name is None:
|
|
316
|
+
return []
|
|
317
|
+
self._tools[item_id] = name
|
|
318
|
+
return [ToolStarted(name, _tool_detail(item))]
|
|
319
|
+
|
|
320
|
+
def _item_completed(self, payload: Mapping[str, Any]) -> list[AgentSignal]:
|
|
321
|
+
item = _mapping(payload.get(_ITEM_FIELD))
|
|
322
|
+
if item is None:
|
|
323
|
+
return []
|
|
324
|
+
signals: list[AgentSignal] = []
|
|
325
|
+
item_id = item.get(_ITEM_ID_FIELD)
|
|
326
|
+
if isinstance(item_id, str):
|
|
327
|
+
name = self._tools.pop(item_id, None)
|
|
328
|
+
if name is not None:
|
|
329
|
+
item_type = item.get(_ITEM_TYPE_FIELD)
|
|
330
|
+
exit_code = (
|
|
331
|
+
_command_exit_code(item) if item_type == _COMMAND_EXECUTION_TYPE else None
|
|
332
|
+
)
|
|
333
|
+
outcome = _tool_outcome(item.get(_ITEM_STATUS_FIELD), exit_code)
|
|
334
|
+
signals.append(ToolFinished(name, outcome, exit_code))
|
|
335
|
+
if item.get(_ITEM_TYPE_FIELD) == _AGENT_MESSAGE_TYPE:
|
|
336
|
+
text = item.get(_MESSAGE_TEXT_FIELD)
|
|
337
|
+
if isinstance(text, str):
|
|
338
|
+
self._last_message = text
|
|
339
|
+
signals.append(Message(text))
|
|
340
|
+
return signals
|
|
341
|
+
|
|
342
|
+
def _failed_signal(
|
|
343
|
+
self,
|
|
344
|
+
payload: Mapping[str, Any],
|
|
345
|
+
stderr: str,
|
|
346
|
+
quota: Sequence[QuotaWindow],
|
|
347
|
+
) -> TurnFailed:
|
|
348
|
+
message, status, code, reset_value = _error_details(payload)
|
|
349
|
+
classification = _classify_error(message, status, code)
|
|
350
|
+
error_reset = (
|
|
351
|
+
reset_value
|
|
352
|
+
if isinstance(reset_value, datetime)
|
|
353
|
+
else self._reset_time.parse(str(reset_value))
|
|
354
|
+
if reset_value is not None
|
|
355
|
+
else None
|
|
356
|
+
)
|
|
357
|
+
reset = (
|
|
358
|
+
error_reset
|
|
359
|
+
or self._reset_time.parse(message)
|
|
360
|
+
or exhausted_reset(quota, self._clock.now())
|
|
361
|
+
)
|
|
362
|
+
kind = FailureKind.UNKNOWN if classification is None else classification.kind
|
|
363
|
+
self._context_exceeded = classification is not None and classification.context_exceeded
|
|
364
|
+
detail = message or _UNKNOWN_ERROR_MESSAGE
|
|
365
|
+
if stderr:
|
|
366
|
+
detail = f"{detail}; stderr: {stderr.strip()}"
|
|
367
|
+
return TurnFailed(kind, detail, reset if kind is FailureKind.QUOTA else None)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
class CodexTurnSequence(TurnProcessSequence):
|
|
371
|
+
"""Build Codex prompt and one context-failure retry process."""
|
|
372
|
+
|
|
373
|
+
def __init__(
|
|
374
|
+
self,
|
|
375
|
+
spec: TurnSpec,
|
|
376
|
+
*,
|
|
377
|
+
args: Sequence[str],
|
|
378
|
+
environment: Callable[[TurnSpec], Mapping[str, str]],
|
|
379
|
+
clock: Clock,
|
|
380
|
+
reset_time: ResetTimeParser,
|
|
381
|
+
home: Path | None = None,
|
|
382
|
+
git_dirs: Sequence[Path],
|
|
383
|
+
compact_at_pct: float,
|
|
384
|
+
model_context_windows: Mapping[str, int] | None = None,
|
|
385
|
+
) -> None:
|
|
386
|
+
self._spec = spec
|
|
387
|
+
self._args = tuple(args)
|
|
388
|
+
self._environment = environment
|
|
389
|
+
self._clock = clock
|
|
390
|
+
self._reset_time = reset_time
|
|
391
|
+
self._home = home or codex_home()
|
|
392
|
+
self._git_dirs = tuple(git_dirs)
|
|
393
|
+
self._compact_at_pct = compact_at_pct
|
|
394
|
+
self._session_id = spec.session_id
|
|
395
|
+
self._phase = _PHASE_START
|
|
396
|
+
self._usage = spec.usage
|
|
397
|
+
self._parser: CodexTurnParser | None = None
|
|
398
|
+
self._context_window = context_window(
|
|
399
|
+
self._home,
|
|
400
|
+
spec.rung.model,
|
|
401
|
+
spec.session_id,
|
|
402
|
+
fallback_context_windows=model_context_windows,
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
def start(self, spec: TurnSpec) -> TurnProcess:
|
|
406
|
+
self._phase = _PHASE_PROMPT
|
|
407
|
+
return self._process(spec)
|
|
408
|
+
|
|
409
|
+
def advance(self, spec: TurnSpec, previous: TurnProcessResult) -> TurnProcess | None:
|
|
410
|
+
self._remember_state(previous.signals)
|
|
411
|
+
terminal = _terminal(previous.signals)
|
|
412
|
+
if (
|
|
413
|
+
self._phase == _PHASE_PROMPT
|
|
414
|
+
and isinstance(terminal, TurnFailed)
|
|
415
|
+
and self._parser is not None
|
|
416
|
+
and self._parser.context_exceeded
|
|
417
|
+
):
|
|
418
|
+
self._phase = _PHASE_RETRY
|
|
419
|
+
return self._process(spec)
|
|
420
|
+
return None
|
|
421
|
+
|
|
422
|
+
def _process(self, spec: TurnSpec) -> TurnProcess:
|
|
423
|
+
argv = [CODEX_COMMAND, EXEC_SUBCOMMAND]
|
|
424
|
+
if self._session_id is not None:
|
|
425
|
+
argv.extend([RESUME_SUBCOMMAND, self._session_id])
|
|
426
|
+
argv.append(JSON_FLAG)
|
|
427
|
+
argv.extend([MODEL_FLAG, spec.rung.model])
|
|
428
|
+
if spec.rung.effort is not None:
|
|
429
|
+
argv.extend([CONFIG_FLAG, f'{REASONING_EFFORT_CONFIG}="{spec.rung.effort}"'])
|
|
430
|
+
token_limit = self._token_limit()
|
|
431
|
+
if token_limit is not None:
|
|
432
|
+
argv.extend([CONFIG_FLAG, f"{AUTO_COMPACT_LIMIT_CONFIG}={token_limit}"])
|
|
433
|
+
roots = _unique_paths((spec.run_dir, *self._git_dirs, *spec.add_dirs))
|
|
434
|
+
argv.extend(sandbox_config_args(roots))
|
|
435
|
+
argv.extend(self._args)
|
|
436
|
+
argv.append(spec.prompt)
|
|
437
|
+
parser = CodexTurnParser(
|
|
438
|
+
session_id=self._session_id,
|
|
439
|
+
initial_usage=self._usage,
|
|
440
|
+
home=self._home,
|
|
441
|
+
clock=self._clock,
|
|
442
|
+
reset_time=self._reset_time,
|
|
443
|
+
)
|
|
444
|
+
self._parser = parser
|
|
445
|
+
return TurnProcess(tuple(argv), spec.cwd, self._environment(spec), parser)
|
|
446
|
+
|
|
447
|
+
def _token_limit(self) -> int | None:
|
|
448
|
+
if self._context_window is None:
|
|
449
|
+
return None
|
|
450
|
+
return int(self._context_window * self._compact_at_pct / _PERCENT)
|
|
451
|
+
|
|
452
|
+
def _remember_state(self, signals: Sequence[AgentSignal]) -> None:
|
|
453
|
+
session_ids = [
|
|
454
|
+
signal.session_id for signal in signals if isinstance(signal, SessionStarted)
|
|
455
|
+
]
|
|
456
|
+
if session_ids:
|
|
457
|
+
self._session_id = session_ids[-1]
|
|
458
|
+
usages = [signal.usage for signal in signals if isinstance(signal, UsageReported)]
|
|
459
|
+
if usages:
|
|
460
|
+
self._usage = usages[-1]
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def context_window(
|
|
464
|
+
home: Path,
|
|
465
|
+
model: str,
|
|
466
|
+
session_id: str | None = None,
|
|
467
|
+
*,
|
|
468
|
+
fallback_context_windows: Mapping[str, int] | None = None,
|
|
469
|
+
) -> int | None:
|
|
470
|
+
"""Return the observed or cached window, then a validated bundled-catalog window."""
|
|
471
|
+
observed = _observed_window(_read_rollout(_rollout_path(home, session_id)), model)
|
|
472
|
+
if observed is not None:
|
|
473
|
+
return observed
|
|
474
|
+
cached = _cached_context_window(home, model)
|
|
475
|
+
if cached is not None:
|
|
476
|
+
return cached
|
|
477
|
+
return None if fallback_context_windows is None else fallback_context_windows.get(model)
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _observed_window(entries: Sequence[Mapping[str, Any]], model: str) -> int | None:
|
|
481
|
+
current_model: object = None
|
|
482
|
+
window: int | None = None
|
|
483
|
+
window_model: object = None
|
|
484
|
+
for entry in entries:
|
|
485
|
+
payload = _mapping(entry.get(_PAYLOAD_FIELD))
|
|
486
|
+
if payload is None:
|
|
487
|
+
continue
|
|
488
|
+
if entry.get(_TYPE_FIELD) == _TURN_CONTEXT_TYPE:
|
|
489
|
+
current_model = payload.get(_MODEL_FIELD)
|
|
490
|
+
elif payload.get(_TYPE_FIELD) == USAGE_EVENT_TYPE:
|
|
491
|
+
info = _mapping(payload.get(_INFO_FIELD))
|
|
492
|
+
value = info.get(_CONTEXT_WINDOW_INFO_FIELD) if info is not None else None
|
|
493
|
+
if isinstance(value, int):
|
|
494
|
+
window, window_model = value, current_model
|
|
495
|
+
return window if window_model == model else None
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def _cached_context_window(home: Path, model: str) -> int | None:
|
|
499
|
+
path = home / MODELS_CACHE_FILENAME
|
|
500
|
+
if not path.is_file():
|
|
501
|
+
return None
|
|
502
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
503
|
+
if not isinstance(raw, Mapping):
|
|
504
|
+
return None
|
|
505
|
+
raw_mapping = cast("Mapping[str, Any]", raw)
|
|
506
|
+
models = raw_mapping.get(_MODELS_FIELD)
|
|
507
|
+
if not isinstance(models, list):
|
|
508
|
+
return None
|
|
509
|
+
for item in cast("list[object]", models):
|
|
510
|
+
entry = _mapping(item)
|
|
511
|
+
if entry is None or entry.get(_MODEL_SLUG_FIELD) != model:
|
|
512
|
+
continue
|
|
513
|
+
value = entry.get(_MODEL_CONTEXT_WINDOW_FIELD)
|
|
514
|
+
return value if isinstance(value, int) and value > 0 else None
|
|
515
|
+
return None
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _rollout_path(home: Path, session_id: str | None) -> Path | None:
|
|
519
|
+
if session_id is None:
|
|
520
|
+
return None
|
|
521
|
+
sessions = home / "sessions"
|
|
522
|
+
matches = list(sessions.rglob(f"rollout-*-{session_id}.jsonl")) if sessions.is_dir() else []
|
|
523
|
+
return max(matches, key=lambda path: path.stat().st_mtime_ns) if matches else None
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _read_rollout(path: Path | None) -> list[Mapping[str, Any]]:
|
|
527
|
+
if path is None or not path.is_file():
|
|
528
|
+
return []
|
|
529
|
+
entries: list[Mapping[str, Any]] = []
|
|
530
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
531
|
+
try:
|
|
532
|
+
raw = json.loads(line)
|
|
533
|
+
except json.JSONDecodeError:
|
|
534
|
+
continue
|
|
535
|
+
if isinstance(raw, Mapping):
|
|
536
|
+
entries.append(cast("Mapping[str, Any]", raw))
|
|
537
|
+
return entries
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _compaction_count(entries: Sequence[Mapping[str, Any]]) -> int:
|
|
541
|
+
return sum(1 for entry in entries if _rollout_type(entry) == _COMPACTED_TYPE)
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _compaction_signals(
|
|
545
|
+
entries: Sequence[Mapping[str, Any]], previous_count: int
|
|
546
|
+
) -> list[Compacted]:
|
|
547
|
+
compacted = [
|
|
548
|
+
(index, entry)
|
|
549
|
+
for index, entry in enumerate(entries)
|
|
550
|
+
if _rollout_type(entry) == _COMPACTED_TYPE
|
|
551
|
+
]
|
|
552
|
+
return [
|
|
553
|
+
_compaction_signal(entries, index, entry) for index, entry in compacted[previous_count:]
|
|
554
|
+
]
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def _compaction_signal(
|
|
558
|
+
entries: Sequence[Mapping[str, Any]], index: int, entry: Mapping[str, Any]
|
|
559
|
+
) -> Compacted:
|
|
560
|
+
payload = _mapping(entry.get(_PAYLOAD_FIELD)) or entry
|
|
561
|
+
pre_tokens = _optional_integer(payload, _PRE_TOKENS_FIELD)
|
|
562
|
+
post_tokens = _optional_integer(payload, _POST_TOKENS_FIELD)
|
|
563
|
+
return Compacted(
|
|
564
|
+
pre_tokens=(
|
|
565
|
+
pre_tokens
|
|
566
|
+
if pre_tokens is not None
|
|
567
|
+
else _nearby_token_count(entries, index, before=True)
|
|
568
|
+
),
|
|
569
|
+
post_tokens=(
|
|
570
|
+
post_tokens
|
|
571
|
+
if post_tokens is not None
|
|
572
|
+
else _nearby_token_count(entries, index, before=False)
|
|
573
|
+
),
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def _nearby_token_count(
|
|
578
|
+
entries: Sequence[Mapping[str, Any]], compacted_index: int, *, before: bool
|
|
579
|
+
) -> int | None:
|
|
580
|
+
candidates = (
|
|
581
|
+
reversed(entries[:compacted_index]) if before else iter(entries[compacted_index + 1 :])
|
|
582
|
+
)
|
|
583
|
+
for entry in candidates:
|
|
584
|
+
if _rollout_type(entry) != USAGE_EVENT_TYPE:
|
|
585
|
+
continue
|
|
586
|
+
payload = _mapping(entry.get(_PAYLOAD_FIELD))
|
|
587
|
+
info = _mapping(payload.get(_INFO_FIELD)) if payload is not None else None
|
|
588
|
+
last_usage = _mapping(info.get(_LAST_USAGE_FIELD)) if info is not None else None
|
|
589
|
+
value = _optional_integer(last_usage, _INPUT_TOKENS_FIELD) if last_usage else None
|
|
590
|
+
if value is not None and (before or value > _ZERO_TOKENS):
|
|
591
|
+
return value
|
|
592
|
+
return None
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def _rollout_type(entry: Mapping[str, Any]) -> object:
|
|
596
|
+
if entry.get(_TYPE_FIELD) != _EVENT_MESSAGE_TYPE:
|
|
597
|
+
return entry.get(_TYPE_FIELD)
|
|
598
|
+
payload = _mapping(entry.get(_PAYLOAD_FIELD))
|
|
599
|
+
return payload.get(_TYPE_FIELD) if payload is not None else None
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def _quota_observation(
|
|
603
|
+
path: Path | None,
|
|
604
|
+
entries: Sequence[Mapping[str, Any]],
|
|
605
|
+
home: Path,
|
|
606
|
+
clock: Clock,
|
|
607
|
+
reset_time: ResetTimeParser,
|
|
608
|
+
) -> QuotaObservation | None:
|
|
609
|
+
if path is None or not entries:
|
|
610
|
+
return None
|
|
611
|
+
probe = CodexQuotaProbe(home, clock, reset_time)
|
|
612
|
+
return probe.parse_observation(path.read_text(encoding="utf-8"), clock.now())
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def _quota_reading(
|
|
616
|
+
windows: Sequence[QuotaWindow],
|
|
617
|
+
) -> tuple[tuple[str, float, datetime | None], ...]:
|
|
618
|
+
return tuple((window.name, window.used_pct, window.resets_at) for window in windows)
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def _quota_is_newer(candidate: QuotaObservation, current: Sequence[QuotaWindow]) -> bool:
|
|
622
|
+
if not current:
|
|
623
|
+
return True
|
|
624
|
+
if candidate.timestamp is None:
|
|
625
|
+
return False
|
|
626
|
+
current_time = max(window.observed_at for window in current)
|
|
627
|
+
return candidate.timestamp > current_time
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def _reported_usage(
|
|
631
|
+
entries: Sequence[Mapping[str, Any]],
|
|
632
|
+
turn_usage: Mapping[str, Any] | None,
|
|
633
|
+
previous: Usage,
|
|
634
|
+
) -> Usage | None:
|
|
635
|
+
latest = latest_token_count(entries)
|
|
636
|
+
if latest is not None:
|
|
637
|
+
return rollout_usage(entries)
|
|
638
|
+
token_fields = (
|
|
639
|
+
_INPUT_TOKENS_FIELD,
|
|
640
|
+
_OUTPUT_TOKENS_FIELD,
|
|
641
|
+
_CACHED_INPUT_TOKENS_FIELD,
|
|
642
|
+
)
|
|
643
|
+
if turn_usage is not None and any(field in turn_usage for field in token_fields):
|
|
644
|
+
return _usage_from_turn(turn_usage, previous)
|
|
645
|
+
return None
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def _usage_from_turn(payload: Mapping[str, Any], previous: Usage) -> Usage:
|
|
649
|
+
def token_value(key: str, fallback: int) -> int:
|
|
650
|
+
value = payload.get(key)
|
|
651
|
+
return int(value) if isinstance(value, int | float) else fallback
|
|
652
|
+
|
|
653
|
+
return Usage(
|
|
654
|
+
input_tokens=token_value(_INPUT_TOKENS_FIELD, previous.input_tokens),
|
|
655
|
+
output_tokens=token_value(_OUTPUT_TOKENS_FIELD, previous.output_tokens),
|
|
656
|
+
cache_read_tokens=token_value(_CACHED_INPUT_TOKENS_FIELD, previous.cache_read_tokens),
|
|
657
|
+
cache_write_tokens=previous.cache_write_tokens,
|
|
658
|
+
cost_usd=None,
|
|
659
|
+
cost_basis="unknown",
|
|
660
|
+
context_used_pct=previous.context_used_pct,
|
|
661
|
+
)
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def _error_details(
|
|
665
|
+
payload: Mapping[str, Any],
|
|
666
|
+
) -> tuple[str, int | None, str, object | None]:
|
|
667
|
+
nested = payload.get(_MESSAGE_FIELD)
|
|
668
|
+
if isinstance(nested, str):
|
|
669
|
+
try:
|
|
670
|
+
decoded = json.loads(nested)
|
|
671
|
+
except json.JSONDecodeError:
|
|
672
|
+
decoded = None
|
|
673
|
+
if isinstance(decoded, Mapping):
|
|
674
|
+
payload = cast("Mapping[str, Any]", decoded)
|
|
675
|
+
message = payload.get(_MESSAGE_FIELD)
|
|
676
|
+
status_value = payload.get(_STATUS_FIELD)
|
|
677
|
+
status = status_value if isinstance(status_value, int) else None
|
|
678
|
+
code = payload.get(_CODE_FIELD)
|
|
679
|
+
message_text = message if isinstance(message, str) else ""
|
|
680
|
+
code_text = code if isinstance(code, str) else ""
|
|
681
|
+
reset_value = payload.get(_RESET_AT_FIELD)
|
|
682
|
+
return message_text or code_text, status, code_text, reset_value
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
class _Classification(NamedTuple):
|
|
686
|
+
kind: FailureKind
|
|
687
|
+
context_exceeded: bool = False
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def _classify_error(message: str, status: int | None, code: str) -> _Classification | None:
|
|
691
|
+
text = f"{code} {message}".lower()
|
|
692
|
+
if (
|
|
693
|
+
status == _PAYMENT_REQUIRED_STATUS
|
|
694
|
+
or status in _AUTH_STATUSES
|
|
695
|
+
or any(token in text for token in _AUTH_TEXT)
|
|
696
|
+
):
|
|
697
|
+
return _Classification(FailureKind.AUTH)
|
|
698
|
+
if code.lower() != _CONTEXT_LENGTH_EXCEEDED_CODE and any(
|
|
699
|
+
token in text for token in _CAPACITY_TEXT
|
|
700
|
+
):
|
|
701
|
+
return _Classification(FailureKind.UNAVAILABLE)
|
|
702
|
+
if status == _RATE_LIMIT_STATUS or any(token in text for token in _QUOTA_TEXT):
|
|
703
|
+
return _Classification(FailureKind.QUOTA)
|
|
704
|
+
if (status is not None and _UNAVAILABLE_STATUS_MIN <= status <= _UNAVAILABLE_STATUS_MAX) or any(
|
|
705
|
+
token in text for token in _UNAVAILABLE_TEXT
|
|
706
|
+
):
|
|
707
|
+
return _Classification(FailureKind.UNAVAILABLE)
|
|
708
|
+
if any(token in text for token in _CONTEXT_TEXT):
|
|
709
|
+
return _Classification(FailureKind.OTHER, context_exceeded=True)
|
|
710
|
+
return None
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def _mapping(value: object) -> Mapping[str, Any] | None:
|
|
714
|
+
return cast("Mapping[str, Any]", value) if isinstance(value, Mapping) else None
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def _optional_integer(payload: Mapping[str, Any], key: str) -> int | None:
|
|
718
|
+
value = payload.get(key)
|
|
719
|
+
return value if isinstance(value, int) else None
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def _tool_name(item_type: str) -> str | None:
|
|
723
|
+
return {
|
|
724
|
+
_COMMAND_EXECUTION_TYPE: _COMMAND_TOOL_NAME,
|
|
725
|
+
_FILE_CHANGE_TYPE: _FILE_CHANGE_TOOL_NAME,
|
|
726
|
+
_MCP_TOOL_CALL_TYPE: _MCP_TOOL_NAME,
|
|
727
|
+
}.get(item_type)
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def _tool_detail(item: Mapping[str, Any]) -> str:
|
|
731
|
+
value = item.get("command")
|
|
732
|
+
if isinstance(value, str):
|
|
733
|
+
return value
|
|
734
|
+
return ""
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def _command_exit_code(item: Mapping[str, Any]) -> int | None:
|
|
738
|
+
value = item.get(_EXIT_CODE_FIELD)
|
|
739
|
+
return value if type(value) is int else None
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
def _tool_outcome(status: object, exit_code: int | None) -> ToolOutcome | None:
|
|
743
|
+
if status == _FAILED_STATUS:
|
|
744
|
+
return ToolOutcome.FAILURE
|
|
745
|
+
if exit_code is None:
|
|
746
|
+
return None
|
|
747
|
+
return ToolOutcome.SUCCESS if exit_code == _ZERO_EXIT_CODE else ToolOutcome.FAILURE
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
def _unique_paths(paths: Sequence[Path | None]) -> tuple[Path, ...]:
|
|
751
|
+
unique: list[Path] = []
|
|
752
|
+
seen: set[Path] = set()
|
|
753
|
+
for path in paths:
|
|
754
|
+
if path is None:
|
|
755
|
+
continue
|
|
756
|
+
normalized = path.resolve()
|
|
757
|
+
if normalized not in seen:
|
|
758
|
+
seen.add(normalized)
|
|
759
|
+
unique.append(path)
|
|
760
|
+
return tuple(unique)
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
def _terminal(signals: Sequence[AgentSignal]) -> TurnEnded | TurnFailed | None:
|
|
764
|
+
return next(
|
|
765
|
+
(signal for signal in reversed(signals) if isinstance(signal, (TurnEnded, TurnFailed))),
|
|
766
|
+
None,
|
|
767
|
+
)
|