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,486 @@
|
|
|
1
|
+
"""Codex headless turn plugin."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import tomllib
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import TYPE_CHECKING, cast
|
|
10
|
+
|
|
11
|
+
from pydantic import ValidationError
|
|
12
|
+
|
|
13
|
+
from shikiru.agents.contracts import (
|
|
14
|
+
BILLING_SOURCE_OPERATOR_CONFIG,
|
|
15
|
+
DOCTOR_TIMEOUT_SECONDS,
|
|
16
|
+
UNKNOWN_BILLING_DECLARATION,
|
|
17
|
+
BillingDeclaration,
|
|
18
|
+
CheckResult,
|
|
19
|
+
ModelCatalog,
|
|
20
|
+
)
|
|
21
|
+
from shikiru.agents.sdk.reset_time import ResetTimeParser
|
|
22
|
+
from shikiru.agents.sdk.turn import StreamingTurn
|
|
23
|
+
from shikiru.clock import from_local
|
|
24
|
+
from shikiru.errors import CommandFailed, ConfigError, ShikiruError
|
|
25
|
+
from shikiru.plugins.agents.codex.headless import CodexHeadless
|
|
26
|
+
from shikiru.plugins.agents.codex.home import codex_home
|
|
27
|
+
from shikiru.plugins.agents.codex.model_catalog import (
|
|
28
|
+
parse_bundled_context_windows,
|
|
29
|
+
parse_catalog,
|
|
30
|
+
)
|
|
31
|
+
from shikiru.plugins.agents.codex.options import (
|
|
32
|
+
CODEX_BUNDLED_MODELS_ARGV,
|
|
33
|
+
CODEX_COMMAND,
|
|
34
|
+
CODEX_HOME_ENV_VAR,
|
|
35
|
+
CODEX_MODEL_CATALOG_ARGV,
|
|
36
|
+
CODEX_SETTINGS,
|
|
37
|
+
CONFIG_FLAG,
|
|
38
|
+
CONFIG_LONG_FLAG,
|
|
39
|
+
EFFORT_LONG_FLAG,
|
|
40
|
+
GIT_DIRS_ARGV,
|
|
41
|
+
GIT_DIRS_TIMEOUT_SECONDS,
|
|
42
|
+
LOGIN_STATUS_ARGV,
|
|
43
|
+
MODEL_CATALOG_ARGS_DETAIL,
|
|
44
|
+
MODEL_CONFIG_OVERRIDE,
|
|
45
|
+
MODEL_FLAG,
|
|
46
|
+
MODEL_LONG_FLAG,
|
|
47
|
+
MODEL_OVERRIDE,
|
|
48
|
+
NO_DAEMON_FLAG,
|
|
49
|
+
PROFILE_FLAG,
|
|
50
|
+
REASONING_EFFORT_CONFIG,
|
|
51
|
+
RESUME_SUBCOMMAND,
|
|
52
|
+
TESTED_CODEX_VERSION,
|
|
53
|
+
VERSION_ARGV,
|
|
54
|
+
CodexOptions,
|
|
55
|
+
)
|
|
56
|
+
from shikiru.plugins.agents.codex.quota import CodexQuotaProbe
|
|
57
|
+
from shikiru.plugins.agents.codex.sandbox_probe import CodexSandboxProbe
|
|
58
|
+
from shikiru.plugins.agents.codex.turn import CodexTurnSequence, context_window
|
|
59
|
+
from shikiru.plugins.agents.shared_settings import options_error
|
|
60
|
+
|
|
61
|
+
if TYPE_CHECKING:
|
|
62
|
+
from collections.abc import Sequence
|
|
63
|
+
from datetime import datetime
|
|
64
|
+
|
|
65
|
+
from shikiru.agents.contracts import (
|
|
66
|
+
AgentContext,
|
|
67
|
+
HeadlessAgent,
|
|
68
|
+
QuotaReading,
|
|
69
|
+
Rung,
|
|
70
|
+
Turn,
|
|
71
|
+
TurnSpec,
|
|
72
|
+
)
|
|
73
|
+
from shikiru.agents.settings import AgentSetting
|
|
74
|
+
from shikiru.clock import Clock
|
|
75
|
+
from shikiru.process import SubprocessRunner
|
|
76
|
+
|
|
77
|
+
_AUTH_NOT_LOGGED_IN_TEXT = "not logged in"
|
|
78
|
+
_AUTH_LOGGED_IN_TEXTS = ("logged in", "authenticated")
|
|
79
|
+
_CODEX_CONFIG_FILENAME = "config.toml"
|
|
80
|
+
_MODEL_PROVIDER_FIELD = "model_provider"
|
|
81
|
+
_MODEL_PROVIDERS_FIELD = "model_providers"
|
|
82
|
+
_OPENAI_BASE_URL_FIELD = "openai_base_url"
|
|
83
|
+
_PROFILES_FIELD = "profiles"
|
|
84
|
+
_PROFILE_FIELD = "profile"
|
|
85
|
+
_PARTITION_VALUE_INDEX = 2
|
|
86
|
+
_CONTEXT_WINDOW_CHECK = "context_window"
|
|
87
|
+
_UNTESTED_VERSION_DETAIL = "{version} (warning: verified with {tested}; re-check the sandbox probe)"
|
|
88
|
+
_UNKNOWN_WINDOW_DETAIL = (
|
|
89
|
+
"no positive context window for {models} in models_cache.json or Codex CLI's bundled "
|
|
90
|
+
"model catalog; compaction at compact_at_pct cannot be applied"
|
|
91
|
+
)
|
|
92
|
+
_MODELS_CACHE_SOURCE = "models_cache.json"
|
|
93
|
+
_BUNDLED_CATALOG_SOURCE = "Codex CLI bundled model catalog"
|
|
94
|
+
_CURRENT_CATALOG_SOURCE = "Codex CLI current model catalog"
|
|
95
|
+
_CUSTOM_PROVIDER_ACCESS_DETAIL = (
|
|
96
|
+
"custom provider configured; provider credentials are not verified by doctor"
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class CodexAgent:
|
|
101
|
+
"""Build Codex turn processes and health checks."""
|
|
102
|
+
|
|
103
|
+
name = "codex"
|
|
104
|
+
|
|
105
|
+
def __init__(self, runner: SubprocessRunner, clock: Clock, options: CodexOptions) -> None:
|
|
106
|
+
self._runner = runner
|
|
107
|
+
self._clock = clock
|
|
108
|
+
self._options = options
|
|
109
|
+
self._reset_time = ResetTimeParser(clock, resolve_local=from_local)
|
|
110
|
+
self._billing_declaration = (
|
|
111
|
+
BillingDeclaration(options.billing_mode, BILLING_SOURCE_OPERATOR_CONFIG)
|
|
112
|
+
if options.billing_mode is not None
|
|
113
|
+
else UNKNOWN_BILLING_DECLARATION
|
|
114
|
+
)
|
|
115
|
+
self._codex_home = codex_home()
|
|
116
|
+
self._quota_probe = CodexQuotaProbe(self._codex_home, clock, self._reset_time)
|
|
117
|
+
self._sandbox_probe = CodexSandboxProbe(runner)
|
|
118
|
+
self._catalog_context_windows: dict[str, int] = {}
|
|
119
|
+
self._bundled_context_windows: dict[str, int] = {}
|
|
120
|
+
self._cached_model_catalog: ModelCatalog | None = None
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def billing_declaration(self) -> BillingDeclaration:
|
|
124
|
+
"""Return the explicit plugin option or the unknown declaration."""
|
|
125
|
+
return self._billing_declaration
|
|
126
|
+
|
|
127
|
+
def read_quota(self, since: datetime) -> QuotaReading | None:
|
|
128
|
+
"""Return the newest Codex-bucket reading in this account's rollouts after `since`."""
|
|
129
|
+
return self._quota_probe.latest_reading(since)
|
|
130
|
+
|
|
131
|
+
async def model_catalog(self) -> ModelCatalog:
|
|
132
|
+
"""Return choices from Codex CLI's refreshed current model catalog."""
|
|
133
|
+
if self._cached_model_catalog is None:
|
|
134
|
+
self._cached_model_catalog = await self._read_model_catalog()
|
|
135
|
+
return self._cached_model_catalog
|
|
136
|
+
|
|
137
|
+
async def start_turn(self, spec: TurnSpec) -> Turn:
|
|
138
|
+
"""Start one Codex process-per-turn exchange."""
|
|
139
|
+
model_context_windows = await self._context_windows_for_turn(spec)
|
|
140
|
+
git_dirs = await self._git_dirs_for_turn(spec)
|
|
141
|
+
sequence = self.process_sequence(
|
|
142
|
+
spec, git_dirs=git_dirs, model_context_windows=model_context_windows
|
|
143
|
+
)
|
|
144
|
+
return await StreamingTurn.start(spec, self._runner, sequence)
|
|
145
|
+
|
|
146
|
+
def process_sequence(
|
|
147
|
+
self,
|
|
148
|
+
spec: TurnSpec,
|
|
149
|
+
*,
|
|
150
|
+
git_dirs: Sequence[Path],
|
|
151
|
+
model_context_windows: Mapping[str, int] | None = None,
|
|
152
|
+
) -> CodexTurnSequence:
|
|
153
|
+
"""Build Codex's prompt and context-failure retry sequence for `spec`."""
|
|
154
|
+
home = Path(spec.env.get(CODEX_HOME_ENV_VAR, str(self._codex_home)))
|
|
155
|
+
return CodexTurnSequence(
|
|
156
|
+
spec,
|
|
157
|
+
args=self._options.args,
|
|
158
|
+
environment=self._turn_environment,
|
|
159
|
+
clock=self._clock,
|
|
160
|
+
reset_time=self._reset_time,
|
|
161
|
+
home=home,
|
|
162
|
+
git_dirs=git_dirs,
|
|
163
|
+
compact_at_pct=self._options.compact_at_pct,
|
|
164
|
+
model_context_windows=(
|
|
165
|
+
self._bundled_context_windows
|
|
166
|
+
if model_context_windows is None
|
|
167
|
+
else model_context_windows
|
|
168
|
+
),
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
def interactive_command(self, session_id: str, run_dir: Path, rung: Rung) -> Sequence[str]:
|
|
172
|
+
"""Return Codex's interactive resume command for a headless session and rung."""
|
|
173
|
+
del run_dir
|
|
174
|
+
argv = [
|
|
175
|
+
CODEX_COMMAND,
|
|
176
|
+
RESUME_SUBCOMMAND,
|
|
177
|
+
NO_DAEMON_FLAG,
|
|
178
|
+
session_id,
|
|
179
|
+
MODEL_FLAG,
|
|
180
|
+
rung.model,
|
|
181
|
+
]
|
|
182
|
+
if rung.effort is not None:
|
|
183
|
+
argv.extend((CONFIG_FLAG, f'{REASONING_EFFORT_CONFIG}="{rung.effort}"'))
|
|
184
|
+
argv.extend(self._options.args)
|
|
185
|
+
return tuple(argv)
|
|
186
|
+
|
|
187
|
+
async def doctor(self, models: Sequence[str]) -> list[CheckResult]:
|
|
188
|
+
"""Check Codex's binary, version, native access and each model's context window."""
|
|
189
|
+
binary = await self._check_binary()
|
|
190
|
+
if not binary.ok:
|
|
191
|
+
unavailable = "binary unavailable"
|
|
192
|
+
return [
|
|
193
|
+
binary,
|
|
194
|
+
CheckResult("version", False, unavailable),
|
|
195
|
+
CheckResult("login", False, unavailable),
|
|
196
|
+
]
|
|
197
|
+
checks = [
|
|
198
|
+
binary,
|
|
199
|
+
await self._check_version(),
|
|
200
|
+
await self._check_login(),
|
|
201
|
+
await self._sandbox_probe.check(),
|
|
202
|
+
]
|
|
203
|
+
if models:
|
|
204
|
+
checks.append(await self._check_context_windows(models))
|
|
205
|
+
return checks
|
|
206
|
+
|
|
207
|
+
async def _check_context_windows(self, models: Sequence[str]) -> CheckResult:
|
|
208
|
+
cached_windows = {model: context_window(self._codex_home, model) for model in models}
|
|
209
|
+
missing_from_catalog = [
|
|
210
|
+
model
|
|
211
|
+
for model, window in cached_windows.items()
|
|
212
|
+
if window is None and model not in self._catalog_context_windows
|
|
213
|
+
]
|
|
214
|
+
if missing_from_catalog:
|
|
215
|
+
catalog_result = await self._read_bundled_model_catalog(missing_from_catalog)
|
|
216
|
+
if isinstance(catalog_result, CheckResult):
|
|
217
|
+
return catalog_result
|
|
218
|
+
self._bundled_context_windows = catalog_result
|
|
219
|
+
self._catalog_context_windows.update(catalog_result)
|
|
220
|
+
windows = {
|
|
221
|
+
model: cached_windows[model]
|
|
222
|
+
if cached_windows[model] is not None
|
|
223
|
+
else self._catalog_context_windows.get(model)
|
|
224
|
+
for model in models
|
|
225
|
+
}
|
|
226
|
+
unknown = [model for model, window in windows.items() if window is None]
|
|
227
|
+
if unknown:
|
|
228
|
+
return CheckResult(
|
|
229
|
+
_CONTEXT_WINDOW_CHECK,
|
|
230
|
+
False,
|
|
231
|
+
_UNKNOWN_WINDOW_DETAIL.format(models=", ".join(unknown)),
|
|
232
|
+
)
|
|
233
|
+
known_parts: list[str] = []
|
|
234
|
+
for model, window in windows.items():
|
|
235
|
+
if cached_windows[model] is not None:
|
|
236
|
+
source = _MODELS_CACHE_SOURCE
|
|
237
|
+
elif model in self._bundled_context_windows:
|
|
238
|
+
source = _BUNDLED_CATALOG_SOURCE
|
|
239
|
+
else:
|
|
240
|
+
source = _CURRENT_CATALOG_SOURCE
|
|
241
|
+
known_parts.append(f"{model}: {window} ({source})")
|
|
242
|
+
known = ", ".join(known_parts)
|
|
243
|
+
return CheckResult(_CONTEXT_WINDOW_CHECK, True, known)
|
|
244
|
+
|
|
245
|
+
async def _read_model_catalog(self) -> ModelCatalog:
|
|
246
|
+
if _model_catalog_args_are_ambiguous(self._options.args):
|
|
247
|
+
return ModelCatalog(error=MODEL_CATALOG_ARGS_DETAIL)
|
|
248
|
+
return await self._query_model_catalog()
|
|
249
|
+
|
|
250
|
+
async def _query_model_catalog(self) -> ModelCatalog:
|
|
251
|
+
try:
|
|
252
|
+
result = await self._runner.run(
|
|
253
|
+
CODEX_MODEL_CATALOG_ARGV,
|
|
254
|
+
timeout=DOCTOR_TIMEOUT_SECONDS,
|
|
255
|
+
env=self._subprocess_environment(),
|
|
256
|
+
check=False,
|
|
257
|
+
)
|
|
258
|
+
except (FileNotFoundError, ShikiruError) as error:
|
|
259
|
+
detail = str(error) or type(error).__name__
|
|
260
|
+
return ModelCatalog(error=f"could not read Codex CLI's current model catalog: {detail}")
|
|
261
|
+
if result.returncode != 0:
|
|
262
|
+
detail = result.stderr.strip() or "no diagnostic output"
|
|
263
|
+
return ModelCatalog(
|
|
264
|
+
error=(
|
|
265
|
+
f"Codex CLI's current model catalog command failed with exit code "
|
|
266
|
+
f"{result.returncode}: {detail}"
|
|
267
|
+
)
|
|
268
|
+
)
|
|
269
|
+
try:
|
|
270
|
+
choices, windows = parse_catalog(result.stdout)
|
|
271
|
+
except TypeError as error:
|
|
272
|
+
return ModelCatalog(error=f"could not parse Codex CLI's current model catalog: {error}")
|
|
273
|
+
self._catalog_context_windows.update(windows)
|
|
274
|
+
return ModelCatalog(models=choices, complete=True)
|
|
275
|
+
|
|
276
|
+
async def _read_bundled_model_catalog(
|
|
277
|
+
self,
|
|
278
|
+
models: Sequence[str],
|
|
279
|
+
*,
|
|
280
|
+
environment: Mapping[str, str] | None = None,
|
|
281
|
+
) -> dict[str, int] | CheckResult:
|
|
282
|
+
model_names = ", ".join(models)
|
|
283
|
+
try:
|
|
284
|
+
result = await self._runner.run(
|
|
285
|
+
CODEX_BUNDLED_MODELS_ARGV,
|
|
286
|
+
timeout=DOCTOR_TIMEOUT_SECONDS,
|
|
287
|
+
env=(self._subprocess_environment() if environment is None else environment),
|
|
288
|
+
check=False,
|
|
289
|
+
)
|
|
290
|
+
except (FileNotFoundError, ShikiruError) as error:
|
|
291
|
+
return CheckResult(
|
|
292
|
+
_CONTEXT_WINDOW_CHECK,
|
|
293
|
+
False,
|
|
294
|
+
f"could not read Codex CLI's bundled model catalog for {model_names}: {error}",
|
|
295
|
+
)
|
|
296
|
+
if result.returncode != 0:
|
|
297
|
+
detail = result.stderr.strip() or result.stdout.strip()
|
|
298
|
+
return CheckResult(
|
|
299
|
+
_CONTEXT_WINDOW_CHECK,
|
|
300
|
+
False,
|
|
301
|
+
f"Codex CLI's bundled model catalog command failed for {model_names} with exit "
|
|
302
|
+
f"code {result.returncode}: {detail or 'no diagnostic output'}",
|
|
303
|
+
)
|
|
304
|
+
try:
|
|
305
|
+
return parse_bundled_context_windows(result.stdout)
|
|
306
|
+
except TypeError as error:
|
|
307
|
+
return CheckResult(
|
|
308
|
+
_CONTEXT_WINDOW_CHECK,
|
|
309
|
+
False,
|
|
310
|
+
f"could not parse Codex CLI's bundled model catalog for {model_names}: {error}",
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
async def _context_windows_for_turn(self, spec: TurnSpec) -> Mapping[str, int]:
|
|
314
|
+
home = Path(spec.env.get(CODEX_HOME_ENV_VAR, str(self._codex_home)))
|
|
315
|
+
model = spec.rung.model
|
|
316
|
+
if context_window(home, model, spec.session_id) is not None:
|
|
317
|
+
return self._bundled_context_windows
|
|
318
|
+
if model in self._bundled_context_windows:
|
|
319
|
+
return self._bundled_context_windows
|
|
320
|
+
catalog = await self._read_bundled_model_catalog(
|
|
321
|
+
(model,), environment=self._turn_environment(spec)
|
|
322
|
+
)
|
|
323
|
+
if isinstance(catalog, CheckResult):
|
|
324
|
+
raise ConfigError(catalog.detail)
|
|
325
|
+
self._bundled_context_windows = catalog
|
|
326
|
+
if model not in catalog:
|
|
327
|
+
raise ConfigError(_UNKNOWN_WINDOW_DETAIL.format(models=model))
|
|
328
|
+
return catalog
|
|
329
|
+
|
|
330
|
+
def settings(self) -> tuple[AgentSetting, ...]:
|
|
331
|
+
"""Return the options worth writing to the project config."""
|
|
332
|
+
return CODEX_SETTINGS
|
|
333
|
+
|
|
334
|
+
def headless(self) -> HeadlessAgent | None:
|
|
335
|
+
"""Return Codex's schema-constrained headless client."""
|
|
336
|
+
return CodexHeadless(self._runner, self._subprocess_environment())
|
|
337
|
+
|
|
338
|
+
def _subprocess_environment(self) -> dict[str, str]:
|
|
339
|
+
return dict(os.environ)
|
|
340
|
+
|
|
341
|
+
def _turn_environment(self, spec: TurnSpec) -> dict[str, str]:
|
|
342
|
+
environment = dict(os.environ)
|
|
343
|
+
environment.update(spec.env)
|
|
344
|
+
return environment
|
|
345
|
+
|
|
346
|
+
async def _git_dirs_for_turn(self, spec: TurnSpec) -> tuple[Path, ...]:
|
|
347
|
+
result = await self._runner.run(
|
|
348
|
+
GIT_DIRS_ARGV,
|
|
349
|
+
timeout=GIT_DIRS_TIMEOUT_SECONDS,
|
|
350
|
+
cwd=spec.cwd,
|
|
351
|
+
check=False,
|
|
352
|
+
)
|
|
353
|
+
values = result.stdout.split()
|
|
354
|
+
if result.returncode != 0 or not values:
|
|
355
|
+
message = (
|
|
356
|
+
f"could not resolve the Codex workspace git directories: {result.stderr.strip()}"
|
|
357
|
+
)
|
|
358
|
+
raise ConfigError(message)
|
|
359
|
+
return tuple(Path(value) for value in values)
|
|
360
|
+
|
|
361
|
+
async def _check_binary(self) -> CheckResult:
|
|
362
|
+
try:
|
|
363
|
+
result = await self._runner.run(
|
|
364
|
+
VERSION_ARGV,
|
|
365
|
+
timeout=DOCTOR_TIMEOUT_SECONDS,
|
|
366
|
+
env=self._subprocess_environment(),
|
|
367
|
+
check=False,
|
|
368
|
+
)
|
|
369
|
+
except (FileNotFoundError, CommandFailed, ShikiruError) as error:
|
|
370
|
+
return CheckResult("binary", False, str(error))
|
|
371
|
+
if result.returncode != 0:
|
|
372
|
+
return CheckResult("binary", False, result.stderr.strip())
|
|
373
|
+
return CheckResult("binary", True, CODEX_COMMAND)
|
|
374
|
+
|
|
375
|
+
async def _check_version(self) -> CheckResult:
|
|
376
|
+
try:
|
|
377
|
+
result = await self._runner.run(
|
|
378
|
+
VERSION_ARGV,
|
|
379
|
+
timeout=DOCTOR_TIMEOUT_SECONDS,
|
|
380
|
+
env=self._subprocess_environment(),
|
|
381
|
+
check=False,
|
|
382
|
+
)
|
|
383
|
+
except (FileNotFoundError, ShikiruError) as error:
|
|
384
|
+
return CheckResult("version", False, str(error))
|
|
385
|
+
detail = result.stdout.strip() or result.stderr.strip()
|
|
386
|
+
if result.returncode == 0 and TESTED_CODEX_VERSION not in detail:
|
|
387
|
+
detail = _UNTESTED_VERSION_DETAIL.format(version=detail, tested=TESTED_CODEX_VERSION)
|
|
388
|
+
return CheckResult("version", result.returncode == 0, detail)
|
|
389
|
+
|
|
390
|
+
async def _check_login(self) -> CheckResult:
|
|
391
|
+
configured_provider = _custom_provider_check(self._codex_home)
|
|
392
|
+
if configured_provider is not None:
|
|
393
|
+
return configured_provider
|
|
394
|
+
try:
|
|
395
|
+
result = await self._runner.run(
|
|
396
|
+
LOGIN_STATUS_ARGV,
|
|
397
|
+
timeout=DOCTOR_TIMEOUT_SECONDS,
|
|
398
|
+
env=self._subprocess_environment(),
|
|
399
|
+
check=False,
|
|
400
|
+
)
|
|
401
|
+
except (FileNotFoundError, ShikiruError) as error:
|
|
402
|
+
return CheckResult("login", False, str(error))
|
|
403
|
+
lowered = f"{result.stdout}\n{result.stderr}".lower()
|
|
404
|
+
logged_in = (
|
|
405
|
+
result.returncode == 0
|
|
406
|
+
and _AUTH_NOT_LOGGED_IN_TEXT not in lowered
|
|
407
|
+
and any(text in lowered for text in _AUTH_LOGGED_IN_TEXTS)
|
|
408
|
+
)
|
|
409
|
+
detail = "authenticated" if logged_in else "not authenticated"
|
|
410
|
+
return CheckResult("login", logged_in, detail)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _custom_provider_check(home: Path) -> CheckResult | None:
|
|
414
|
+
path = home / _CODEX_CONFIG_FILENAME
|
|
415
|
+
if not path.is_file():
|
|
416
|
+
return None
|
|
417
|
+
try:
|
|
418
|
+
config = cast("Mapping[str, object]", tomllib.loads(path.read_text(encoding="utf-8")))
|
|
419
|
+
except (OSError, tomllib.TOMLDecodeError) as error:
|
|
420
|
+
return CheckResult("login", False, f"could not inspect Codex config: {error}")
|
|
421
|
+
profile_name = config.get(_PROFILE_FIELD)
|
|
422
|
+
profiles_value = config.get(_PROFILES_FIELD)
|
|
423
|
+
profiles: Mapping[str, object] = (
|
|
424
|
+
cast("Mapping[str, object]", profiles_value) if isinstance(profiles_value, Mapping) else {}
|
|
425
|
+
)
|
|
426
|
+
profile_value: object | None = (
|
|
427
|
+
profiles.get(profile_name) if isinstance(profile_name, str) else None
|
|
428
|
+
)
|
|
429
|
+
profile: Mapping[str, object] = (
|
|
430
|
+
cast("Mapping[str, object]", profile_value) if isinstance(profile_value, Mapping) else {}
|
|
431
|
+
)
|
|
432
|
+
provider_name: object = profile.get(_MODEL_PROVIDER_FIELD, config.get(_MODEL_PROVIDER_FIELD))
|
|
433
|
+
providers_value = config.get(_MODEL_PROVIDERS_FIELD)
|
|
434
|
+
providers: Mapping[str, object] = (
|
|
435
|
+
cast("Mapping[str, object]", providers_value)
|
|
436
|
+
if isinstance(providers_value, Mapping)
|
|
437
|
+
else {}
|
|
438
|
+
)
|
|
439
|
+
custom_provider = isinstance(provider_name, str) and isinstance(
|
|
440
|
+
providers.get(provider_name), Mapping
|
|
441
|
+
)
|
|
442
|
+
base_url: object = profile.get(_OPENAI_BASE_URL_FIELD, config.get(_OPENAI_BASE_URL_FIELD))
|
|
443
|
+
custom_base_url = isinstance(base_url, str) and bool(base_url.strip())
|
|
444
|
+
if custom_provider or custom_base_url:
|
|
445
|
+
return CheckResult("login", True, _CUSTOM_PROVIDER_ACCESS_DETAIL)
|
|
446
|
+
return None
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _model_catalog_args_are_ambiguous(args: Sequence[str]) -> bool:
|
|
450
|
+
return any(
|
|
451
|
+
_catalog_argument_is_ambiguous(args, index, argument) for index, argument in enumerate(args)
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _catalog_argument_is_ambiguous(args: Sequence[str], index: int, argument: str) -> bool:
|
|
456
|
+
selector_flags = (MODEL_FLAG, MODEL_LONG_FLAG, EFFORT_LONG_FLAG, PROFILE_FLAG)
|
|
457
|
+
selector_prefixes = (f"{MODEL_LONG_FLAG}=", f"{PROFILE_FLAG}=", f"{EFFORT_LONG_FLAG}=")
|
|
458
|
+
if argument in selector_flags or argument.startswith(selector_prefixes):
|
|
459
|
+
return True
|
|
460
|
+
if argument.startswith(MODEL_FLAG) and argument != MODEL_FLAG:
|
|
461
|
+
return True
|
|
462
|
+
if argument in (CONFIG_FLAG, CONFIG_LONG_FLAG):
|
|
463
|
+
return _config_override_is_ambiguous(args, index + 1)
|
|
464
|
+
if argument.startswith(f"{CONFIG_LONG_FLAG}="):
|
|
465
|
+
return _config_value_is_ambiguous(argument.partition("=")[_PARTITION_VALUE_INDEX])
|
|
466
|
+
if argument.startswith(CONFIG_FLAG) and argument != CONFIG_FLAG:
|
|
467
|
+
return _config_value_is_ambiguous(argument[len(CONFIG_FLAG) :])
|
|
468
|
+
return False
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _config_override_is_ambiguous(args: Sequence[str], index: int) -> bool:
|
|
472
|
+
return index >= len(args) or _config_value_is_ambiguous(args[index])
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def _config_value_is_ambiguous(value: str) -> bool:
|
|
476
|
+
key = value.partition("=")[0].strip()
|
|
477
|
+
return key in (MODEL_CONFIG_OVERRIDE, MODEL_OVERRIDE, REASONING_EFFORT_CONFIG)
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def create(context: AgentContext) -> CodexAgent:
|
|
481
|
+
"""Build the Codex plugin from validated `AgentContext.options`."""
|
|
482
|
+
try:
|
|
483
|
+
options = CodexOptions.model_validate(context.options)
|
|
484
|
+
except ValidationError as error:
|
|
485
|
+
raise options_error(CODEX_COMMAND, error) from error
|
|
486
|
+
return CodexAgent(context.runner, context.clock, options)
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Codex schema-constrained one-shot calls."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import tempfile
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
10
|
+
|
|
11
|
+
from shikiru.errors import HeadlessOutputError
|
|
12
|
+
from shikiru.plugins.agents.codex.options import (
|
|
13
|
+
CODEX_COMMAND,
|
|
14
|
+
CONFIG_FLAG,
|
|
15
|
+
EPHEMERAL_FLAG,
|
|
16
|
+
EXEC_SUBCOMMAND,
|
|
17
|
+
JSON_FLAG,
|
|
18
|
+
MODEL_FLAG,
|
|
19
|
+
OUTPUT_SCHEMA_FLAG,
|
|
20
|
+
READ_ONLY_SANDBOX,
|
|
21
|
+
REASONING_EFFORT_CONFIG,
|
|
22
|
+
SANDBOX_FLAG,
|
|
23
|
+
SKIP_GIT_REPO_CHECK_FLAG,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from datetime import timedelta
|
|
28
|
+
|
|
29
|
+
from shikiru.process import SubprocessRunner
|
|
30
|
+
|
|
31
|
+
_AGENT_MESSAGE_TYPE = "agent_message"
|
|
32
|
+
_ITEM_COMPLETED_TYPE = "item.completed"
|
|
33
|
+
_TYPE_FIELD = "type"
|
|
34
|
+
_ITEM_FIELD = "item"
|
|
35
|
+
_TEXT_FIELD = "text"
|
|
36
|
+
_CONTENT_FIELD = "content"
|
|
37
|
+
_SCHEMA_TYPE_FIELD = "type"
|
|
38
|
+
_OBJECT_TYPE = "object"
|
|
39
|
+
_ADDITIONAL_PROPERTIES_FIELD = "additionalProperties"
|
|
40
|
+
_STRICT_SCHEMA_VALUE = False
|
|
41
|
+
_INVALID_JSON_MESSAGE = "Codex headless output was not valid JSON"
|
|
42
|
+
_INVALID_OBJECT_MESSAGE = "Codex headless output was not an object"
|
|
43
|
+
_MISSING_AGENT_MESSAGE = "Codex headless output had no agent_message"
|
|
44
|
+
_STDIN_LINE_ENDING = "\n"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class CodexHeadless:
|
|
48
|
+
"""Runs Codex's ephemeral JSON structured-output mode."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, runner: SubprocessRunner, environment: Mapping[str, str]) -> None:
|
|
51
|
+
"""Use `runner` and the Operator-provided environment."""
|
|
52
|
+
self._runner = runner
|
|
53
|
+
self._environment = dict(environment)
|
|
54
|
+
|
|
55
|
+
async def ask(
|
|
56
|
+
self,
|
|
57
|
+
prompt: str,
|
|
58
|
+
schema: Mapping[str, Any],
|
|
59
|
+
model: str,
|
|
60
|
+
effort: str | None,
|
|
61
|
+
cwd: Path,
|
|
62
|
+
timeout: timedelta,
|
|
63
|
+
) -> Mapping[str, Any]:
|
|
64
|
+
"""Return Codex's schema-constrained final `agent_message` value."""
|
|
65
|
+
with tempfile.TemporaryDirectory(prefix="shikiru-codex-") as directory:
|
|
66
|
+
schema_path = Path(directory) / "output-schema.json"
|
|
67
|
+
schema_path.write_text(
|
|
68
|
+
json.dumps(_strict_schema(schema), separators=(",", ":")), encoding="utf-8"
|
|
69
|
+
)
|
|
70
|
+
argv = [
|
|
71
|
+
CODEX_COMMAND,
|
|
72
|
+
EXEC_SUBCOMMAND,
|
|
73
|
+
EPHEMERAL_FLAG,
|
|
74
|
+
JSON_FLAG,
|
|
75
|
+
OUTPUT_SCHEMA_FLAG,
|
|
76
|
+
str(schema_path),
|
|
77
|
+
MODEL_FLAG,
|
|
78
|
+
model,
|
|
79
|
+
]
|
|
80
|
+
if effort is not None:
|
|
81
|
+
argv.extend([CONFIG_FLAG, f'{REASONING_EFFORT_CONFIG}="{effort}"'])
|
|
82
|
+
argv.extend(
|
|
83
|
+
[
|
|
84
|
+
SANDBOX_FLAG,
|
|
85
|
+
READ_ONLY_SANDBOX,
|
|
86
|
+
SKIP_GIT_REPO_CHECK_FLAG,
|
|
87
|
+
]
|
|
88
|
+
)
|
|
89
|
+
result = await self._runner.run(
|
|
90
|
+
argv,
|
|
91
|
+
timeout=timeout.total_seconds(),
|
|
92
|
+
cwd=cwd,
|
|
93
|
+
env=self._environment,
|
|
94
|
+
input_text=prompt
|
|
95
|
+
if prompt.endswith(_STDIN_LINE_ENDING)
|
|
96
|
+
else prompt + _STDIN_LINE_ENDING,
|
|
97
|
+
check=True,
|
|
98
|
+
)
|
|
99
|
+
return _parse_agent_message(result.stdout)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _strict_schema(value: object) -> object:
|
|
103
|
+
if isinstance(value, Mapping):
|
|
104
|
+
typed_value = cast("Mapping[str, object]", value)
|
|
105
|
+
schema = {key: _strict_schema(item) for key, item in typed_value.items()}
|
|
106
|
+
if schema.get(_SCHEMA_TYPE_FIELD) == _OBJECT_TYPE:
|
|
107
|
+
schema.setdefault(_ADDITIONAL_PROPERTIES_FIELD, _STRICT_SCHEMA_VALUE)
|
|
108
|
+
return schema
|
|
109
|
+
if isinstance(value, list):
|
|
110
|
+
return [_strict_schema(item) for item in cast("list[object]", value)]
|
|
111
|
+
return value
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _parse_agent_message(stdout: str) -> Mapping[str, Any]:
|
|
115
|
+
final_message: str | None = None
|
|
116
|
+
for line in stdout.splitlines():
|
|
117
|
+
try:
|
|
118
|
+
raw = json.loads(line)
|
|
119
|
+
except json.JSONDecodeError:
|
|
120
|
+
continue
|
|
121
|
+
if not isinstance(raw, Mapping):
|
|
122
|
+
continue
|
|
123
|
+
payload = cast("Mapping[str, Any]", raw)
|
|
124
|
+
message = _message_from_payload(payload)
|
|
125
|
+
if message is None:
|
|
126
|
+
continue
|
|
127
|
+
final_message = message
|
|
128
|
+
if final_message is None:
|
|
129
|
+
raise HeadlessOutputError(_MISSING_AGENT_MESSAGE)
|
|
130
|
+
try:
|
|
131
|
+
decoded = json.loads(final_message)
|
|
132
|
+
except json.JSONDecodeError as error:
|
|
133
|
+
raise HeadlessOutputError(_INVALID_JSON_MESSAGE) from error
|
|
134
|
+
if not isinstance(decoded, Mapping):
|
|
135
|
+
raise HeadlessOutputError(_INVALID_OBJECT_MESSAGE)
|
|
136
|
+
return cast("Mapping[str, Any]", decoded)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _message_from_payload(payload: Mapping[str, Any]) -> str | None:
|
|
140
|
+
if payload.get(_TYPE_FIELD) != _ITEM_COMPLETED_TYPE:
|
|
141
|
+
return None
|
|
142
|
+
item = payload.get(_ITEM_FIELD)
|
|
143
|
+
if isinstance(item, Mapping):
|
|
144
|
+
typed_item = cast("Mapping[str, Any]", item)
|
|
145
|
+
if typed_item.get(_TYPE_FIELD) == _AGENT_MESSAGE_TYPE:
|
|
146
|
+
return _text(typed_item)
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _text(payload: Mapping[str, Any]) -> str | None:
|
|
151
|
+
value = payload.get(_TEXT_FIELD)
|
|
152
|
+
if isinstance(value, str):
|
|
153
|
+
return value
|
|
154
|
+
content = payload.get(_CONTENT_FIELD)
|
|
155
|
+
if isinstance(content, str):
|
|
156
|
+
return content
|
|
157
|
+
if isinstance(content, list):
|
|
158
|
+
parts: list[str] = []
|
|
159
|
+
for item in cast("list[object]", content):
|
|
160
|
+
if isinstance(item, Mapping):
|
|
161
|
+
part = cast("Mapping[str, Any]", item).get(_TEXT_FIELD)
|
|
162
|
+
if isinstance(part, str):
|
|
163
|
+
parts.append(part)
|
|
164
|
+
text = "".join(parts)
|
|
165
|
+
return text or None
|
|
166
|
+
return None
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Codex's local session data directory."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from shikiru.plugins.agents.codex.options import CODEX_HOME_ENV_VAR
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def codex_home() -> Path:
|
|
12
|
+
"""Return the Codex home used for rollout discovery."""
|
|
13
|
+
configured = os.environ.get(CODEX_HOME_ENV_VAR)
|
|
14
|
+
return Path(configured) if configured else Path.home() / ".codex"
|