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,653 @@
|
|
|
1
|
+
"""Headless agent contract checks, imported only by tests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
import uuid
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from datetime import timedelta
|
|
13
|
+
from enum import StrEnum, auto
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import TYPE_CHECKING, Any, ClassVar
|
|
16
|
+
|
|
17
|
+
import pytest
|
|
18
|
+
|
|
19
|
+
from shikiru.agents.contracts import (
|
|
20
|
+
COST_BASIS_UNKNOWN,
|
|
21
|
+
AgentContext,
|
|
22
|
+
AgentSignal,
|
|
23
|
+
Compacted,
|
|
24
|
+
FailureKind,
|
|
25
|
+
Message,
|
|
26
|
+
SessionStarted,
|
|
27
|
+
ToolFinished,
|
|
28
|
+
ToolStarted,
|
|
29
|
+
TurnEnded,
|
|
30
|
+
TurnFailed,
|
|
31
|
+
TurnSpec,
|
|
32
|
+
Usage,
|
|
33
|
+
UsageReported,
|
|
34
|
+
)
|
|
35
|
+
from shikiru.agents.sdk.turn import (
|
|
36
|
+
StreamingTurn,
|
|
37
|
+
TurnParser,
|
|
38
|
+
TurnProcess,
|
|
39
|
+
TurnProcessResult,
|
|
40
|
+
TurnProcessSequence,
|
|
41
|
+
)
|
|
42
|
+
from shikiru.agents.testing.resume import RESUME_NONCE_PROMPT, RESUME_RECALL_PROMPT
|
|
43
|
+
from shikiru.clock import SystemClock
|
|
44
|
+
from shikiru.operator.probe import (
|
|
45
|
+
REPORT_COMMAND,
|
|
46
|
+
REPORT_INPUT_FILENAME,
|
|
47
|
+
init_scratch_repo,
|
|
48
|
+
live_report_problem,
|
|
49
|
+
prepare_live_report,
|
|
50
|
+
)
|
|
51
|
+
from shikiru.process import SubprocessRunner
|
|
52
|
+
|
|
53
|
+
if TYPE_CHECKING:
|
|
54
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
55
|
+
|
|
56
|
+
from pydantic import BaseModel
|
|
57
|
+
|
|
58
|
+
from shikiru.agents.contracts import Agent, AgentFactory
|
|
59
|
+
|
|
60
|
+
E2E_ENV_VAR = "SHIKIRU_E2E"
|
|
61
|
+
_TURN_TIMEOUT_SECONDS = 60.0
|
|
62
|
+
_COMMAND_FLAG = "-c"
|
|
63
|
+
_LIVE_COMPACTION_TIMEOUT_SECONDS = 900.0
|
|
64
|
+
_PYTEST_TIMEOUT_MARGIN_SECONDS = 60.0
|
|
65
|
+
_GROWTH_FILES = 6
|
|
66
|
+
_GROWTH_FILE_LINES = 400
|
|
67
|
+
_GROWTH_WORDS_PER_LINE = 10
|
|
68
|
+
_SYLLABLES = ("ka", "ro", "mit", "sel", "dun", "vo", "tra", "ple", "gor", "nis", "bu", "fex")
|
|
69
|
+
_SYLLABLES_PER_WORD = 3
|
|
70
|
+
_LIVE_COMPACTION_PROMPT = (
|
|
71
|
+
f"Read part1.txt through part{_GROWTH_FILES}.txt in this repository in order, each file "
|
|
72
|
+
"in full and one file per tool call. After each file, state its last line number in one "
|
|
73
|
+
"short sentence.\n"
|
|
74
|
+
"Then use Bash to run exactly this report command once:\n"
|
|
75
|
+
f'{REPORT_COMMAND} "$SHIKIRU_RUN_DIR/{REPORT_INPUT_FILENAME}"\n'
|
|
76
|
+
"After the command succeeds, reply with exactly: DONE followed by the nonce."
|
|
77
|
+
)
|
|
78
|
+
_LIVE_COMPACTION_TURN_PROMPT = (
|
|
79
|
+
"Remember this nonce: {nonce}. Then read {prompt_path} and follow it exactly."
|
|
80
|
+
)
|
|
81
|
+
_REPLAY_SCRIPT = """
|
|
82
|
+
import json, sys
|
|
83
|
+
from pathlib import Path
|
|
84
|
+
plan = json.loads(sys.argv[1])
|
|
85
|
+
for path, content in plan["writes"].items():
|
|
86
|
+
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
|
87
|
+
Path(path).write_text(content)
|
|
88
|
+
sys.stdout.write(Path(plan["stdout"]).read_text())
|
|
89
|
+
sys.stderr.write(Path(plan["stderr"]).read_text())
|
|
90
|
+
sys.exit(plan["exit_code"])
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _empty_session_logs() -> Mapping[str, Path]:
|
|
95
|
+
return {}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass(frozen=True)
|
|
99
|
+
class RecordedStream:
|
|
100
|
+
"""One committed stdout/stderr capture and its normalized expected signals."""
|
|
101
|
+
|
|
102
|
+
name: str
|
|
103
|
+
stdout: Path
|
|
104
|
+
stderr: Path
|
|
105
|
+
exit_code: int
|
|
106
|
+
expected_signals: Sequence[AgentSignal]
|
|
107
|
+
session_id: str | None
|
|
108
|
+
session_logs: Mapping[str, Path] = field(default_factory=_empty_session_logs)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass(frozen=True)
|
|
112
|
+
class InvocationCase:
|
|
113
|
+
"""One offline command and environment sequence for an agent turn."""
|
|
114
|
+
|
|
115
|
+
name: str
|
|
116
|
+
options: Mapping[str, Any]
|
|
117
|
+
spec: TurnSpec
|
|
118
|
+
expected_argv: Sequence[tuple[str, ...]]
|
|
119
|
+
expected_env: Sequence[Mapping[str, str]]
|
|
120
|
+
outcome_streams: Sequence[RecordedStream] = ()
|
|
121
|
+
expected_final_signals: Sequence[AgentSignal] = ()
|
|
122
|
+
interactive_session_id: str | None = None
|
|
123
|
+
expected_interactive_command: tuple[str, ...] | None = None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class CompactionCase(StrEnum):
|
|
127
|
+
"""The in-turn compaction rule one offline scenario exercises (check 3)."""
|
|
128
|
+
|
|
129
|
+
THRESHOLD = auto()
|
|
130
|
+
CONTEXT_FAILURE = auto()
|
|
131
|
+
FAILED_RECOVERY = auto()
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _no_writes() -> Mapping[Path, str]:
|
|
135
|
+
return {}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@dataclass(frozen=True)
|
|
139
|
+
class ReplayedProcess:
|
|
140
|
+
"""One recorded process, plus the files its agent wrote while it ran (hook marker, log)."""
|
|
141
|
+
|
|
142
|
+
stream: RecordedStream
|
|
143
|
+
writes: Mapping[Path, str] = field(default_factory=_no_writes)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@dataclass(frozen=True)
|
|
147
|
+
class CompactionScenario:
|
|
148
|
+
"""One turn replayed through the plugin's own process sequence and parsers."""
|
|
149
|
+
|
|
150
|
+
name: str
|
|
151
|
+
case: CompactionCase
|
|
152
|
+
options: Mapping[str, Any]
|
|
153
|
+
spec: TurnSpec
|
|
154
|
+
processes: Sequence[ReplayedProcess]
|
|
155
|
+
expected_argv: Sequence[tuple[str, ...]]
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class AgentContractSuite:
|
|
159
|
+
"""Pytest base class for the process-per-turn agent contract."""
|
|
160
|
+
|
|
161
|
+
runs_live_checks_in_default_gate: ClassVar[bool] = False
|
|
162
|
+
|
|
163
|
+
def agent_factory(self) -> AgentFactory:
|
|
164
|
+
"""Return the plugin's `AgentFactory` entry point."""
|
|
165
|
+
raise NotImplementedError
|
|
166
|
+
|
|
167
|
+
def agent_options(self) -> Mapping[str, Any]:
|
|
168
|
+
"""Return the `agents.<name>.options` used by the live checks."""
|
|
169
|
+
return {}
|
|
170
|
+
|
|
171
|
+
def build_spec(
|
|
172
|
+
self,
|
|
173
|
+
tmp_path: Path,
|
|
174
|
+
*,
|
|
175
|
+
resume_session_id: str | None,
|
|
176
|
+
usage: Usage | None = None,
|
|
177
|
+
model: str | None = None,
|
|
178
|
+
) -> TurnSpec:
|
|
179
|
+
"""Build a new or resumed `TurnSpec` for the plugin under test."""
|
|
180
|
+
raise NotImplementedError
|
|
181
|
+
|
|
182
|
+
def parser_for(self, sample: RecordedStream) -> TurnParser:
|
|
183
|
+
"""Build a fresh parser for a recorded stream and its session-log excerpts."""
|
|
184
|
+
raise NotImplementedError
|
|
185
|
+
|
|
186
|
+
def recorded_streams(self) -> Sequence[RecordedStream]:
|
|
187
|
+
"""Return committed stream captures and expected normalized signals."""
|
|
188
|
+
raise NotImplementedError
|
|
189
|
+
|
|
190
|
+
def error_streams(self) -> Sequence[RecordedStream]:
|
|
191
|
+
"""Return recorded structured and unrecognized errors, including an `UNKNOWN` case."""
|
|
192
|
+
raise NotImplementedError
|
|
193
|
+
|
|
194
|
+
def invocation_cases(self, tmp_path: Path) -> Sequence[InvocationCase]:
|
|
195
|
+
"""Return new, resume, model-change, environment and compaction cases."""
|
|
196
|
+
raise NotImplementedError
|
|
197
|
+
|
|
198
|
+
def process_sequence(self, agent: Agent, spec: TurnSpec) -> TurnProcessSequence:
|
|
199
|
+
"""Return the same process sequence used by `agent.start_turn(spec)`."""
|
|
200
|
+
raise NotImplementedError
|
|
201
|
+
|
|
202
|
+
def compaction_scenarios(self, tmp_path: Path) -> Sequence[CompactionScenario]:
|
|
203
|
+
"""Return recorded turns covering every `CompactionCase` (offline check 3)."""
|
|
204
|
+
raise NotImplementedError
|
|
205
|
+
|
|
206
|
+
def live_compaction_options(self) -> Mapping[str, Any]:
|
|
207
|
+
"""Return options whose low `compact_at_pct` live check 6 grows past in one turn."""
|
|
208
|
+
raise NotImplementedError
|
|
209
|
+
|
|
210
|
+
def headless_ask_schema_model(self) -> type[BaseModel] | None:
|
|
211
|
+
"""Return the result model for `headless().ask()`, when the plugin provides it."""
|
|
212
|
+
return None
|
|
213
|
+
|
|
214
|
+
def headless_ask_model(self) -> str:
|
|
215
|
+
"""Return the model name passed to the live `headless().ask()` check."""
|
|
216
|
+
return "test-model"
|
|
217
|
+
|
|
218
|
+
def headless_ask_prompt(self) -> str:
|
|
219
|
+
"""Return the prompt passed to the live `headless().ask()` check."""
|
|
220
|
+
return "ping"
|
|
221
|
+
|
|
222
|
+
def test_offline_replays_recorded_streams_and_obligations(self) -> None:
|
|
223
|
+
for sample in self.recorded_streams():
|
|
224
|
+
signals = _replay(self.parser_for(sample), sample)
|
|
225
|
+
_expect(signals == list(sample.expected_signals), sample.name)
|
|
226
|
+
assert_agent_obligations(signals, sample.session_id)
|
|
227
|
+
|
|
228
|
+
def test_offline_maps_errors_and_preserves_unknown_failures(self) -> None:
|
|
229
|
+
samples = self.error_streams()
|
|
230
|
+
_expect(bool(samples), "no error samples were provided")
|
|
231
|
+
unknown_found = False
|
|
232
|
+
for sample in samples:
|
|
233
|
+
signals = _replay(self.parser_for(sample), sample)
|
|
234
|
+
_expect(signals == list(sample.expected_signals), sample.name)
|
|
235
|
+
assert_agent_obligations(signals, sample.session_id)
|
|
236
|
+
failure = next(signal for signal in signals if isinstance(signal, TurnFailed))
|
|
237
|
+
unknown_found |= failure.kind is FailureKind.UNKNOWN
|
|
238
|
+
_expect(unknown_found, "unmapped error text did not produce UNKNOWN")
|
|
239
|
+
|
|
240
|
+
@pytest.mark.asyncio
|
|
241
|
+
async def test_offline_compaction_continues_within_one_turn(self, tmp_path: Path) -> None:
|
|
242
|
+
_expect(
|
|
243
|
+
any(
|
|
244
|
+
isinstance(signal, Compacted)
|
|
245
|
+
for sample in self.recorded_streams()
|
|
246
|
+
for signal in sample.expected_signals
|
|
247
|
+
),
|
|
248
|
+
"recorded streams contain no Compacted signal",
|
|
249
|
+
)
|
|
250
|
+
scenarios = self.compaction_scenarios(tmp_path)
|
|
251
|
+
_expect(
|
|
252
|
+
{scenario.case for scenario in scenarios} == set(CompactionCase),
|
|
253
|
+
"compaction scenarios must cover every CompactionCase",
|
|
254
|
+
)
|
|
255
|
+
for scenario in scenarios:
|
|
256
|
+
signals, argv = await self._replay_scenario(scenario)
|
|
257
|
+
_expect(argv == list(scenario.expected_argv), scenario.name)
|
|
258
|
+
assert_agent_obligations(signals, scenario.spec.session_id)
|
|
259
|
+
_expect(_COMPACTION_RULES[scenario.case](signals), scenario.name)
|
|
260
|
+
|
|
261
|
+
async def _replay_scenario(
|
|
262
|
+
self, scenario: CompactionScenario
|
|
263
|
+
) -> tuple[list[AgentSignal], list[tuple[str, ...]]]:
|
|
264
|
+
agent = self._build_agent(scenario.options)
|
|
265
|
+
replay = _ReplaySequence(self.process_sequence(agent, scenario.spec), scenario.processes)
|
|
266
|
+
turn = await StreamingTurn.start(scenario.spec, SubprocessRunner(), replay)
|
|
267
|
+
signals = [signal async for signal in turn.signals()]
|
|
268
|
+
_expect(replay.exhausted, f"{scenario.name}: the turn ended before every process ran")
|
|
269
|
+
return signals, replay.argv
|
|
270
|
+
|
|
271
|
+
def test_offline_builds_expected_argv_environment_and_interactive_commands(
|
|
272
|
+
self, tmp_path: Path
|
|
273
|
+
) -> None:
|
|
274
|
+
for case in self.invocation_cases(tmp_path):
|
|
275
|
+
context = AgentContext(
|
|
276
|
+
options=case.options, runner=SubprocessRunner(), clock=SystemClock()
|
|
277
|
+
)
|
|
278
|
+
agent = self.agent_factory()(context)
|
|
279
|
+
sequence = self.process_sequence(agent, case.spec)
|
|
280
|
+
command: TurnProcess | None = sequence.start(case.spec)
|
|
281
|
+
commands: list[TurnProcess] = []
|
|
282
|
+
outcomes: list[TurnProcessResult] = []
|
|
283
|
+
streams = iter(case.outcome_streams)
|
|
284
|
+
while command is not None:
|
|
285
|
+
commands.append(command)
|
|
286
|
+
sample = next(streams, None)
|
|
287
|
+
if sample is None:
|
|
288
|
+
break
|
|
289
|
+
outcomes.append(_process_outcome(command.parser, sample))
|
|
290
|
+
command = sequence.advance(case.spec, outcomes[-1])
|
|
291
|
+
_expect([item.argv for item in commands] == list(case.expected_argv), case.name)
|
|
292
|
+
_expect(len(commands) == len(case.expected_env), case.name)
|
|
293
|
+
for command, expected in zip(commands, case.expected_env, strict=True):
|
|
294
|
+
_expect(
|
|
295
|
+
all(command.env.get(key) == value for key, value in expected.items()), case.name
|
|
296
|
+
)
|
|
297
|
+
if case.expected_final_signals:
|
|
298
|
+
_expect(bool(outcomes), case.name)
|
|
299
|
+
final_outcome = outcomes[-1]
|
|
300
|
+
final_terminals = [
|
|
301
|
+
signal
|
|
302
|
+
for signal in final_outcome.signals
|
|
303
|
+
if isinstance(signal, (TurnEnded, TurnFailed))
|
|
304
|
+
]
|
|
305
|
+
expected_terminals = [
|
|
306
|
+
signal
|
|
307
|
+
for signal in case.expected_final_signals
|
|
308
|
+
if isinstance(signal, (TurnEnded, TurnFailed))
|
|
309
|
+
]
|
|
310
|
+
_expect(final_terminals == expected_terminals, case.name)
|
|
311
|
+
assert_agent_obligations(case.expected_final_signals, case.spec.session_id)
|
|
312
|
+
if case.interactive_session_id is not None:
|
|
313
|
+
_expect(case.expected_interactive_command is not None, case.name)
|
|
314
|
+
restarted = self.agent_factory()(context)
|
|
315
|
+
_expect(
|
|
316
|
+
tuple(
|
|
317
|
+
restarted.interactive_command(
|
|
318
|
+
case.interactive_session_id, case.spec.run_dir, case.spec.rung
|
|
319
|
+
)
|
|
320
|
+
)
|
|
321
|
+
== case.expected_interactive_command,
|
|
322
|
+
case.name,
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
@pytest.mark.asyncio
|
|
326
|
+
async def test_live_first_turn_writes_a_valid_report(self, tmp_path: Path) -> None:
|
|
327
|
+
self._require_live()
|
|
328
|
+
spec = await self._prepare_live_report_spec(
|
|
329
|
+
self.build_spec(tmp_path, resume_session_id=None)
|
|
330
|
+
)
|
|
331
|
+
signals = await self._run_turn(spec)
|
|
332
|
+
problem = await asyncio.to_thread(live_report_problem, spec, signals)
|
|
333
|
+
_expect(problem is None, problem or "")
|
|
334
|
+
|
|
335
|
+
async def _prepare_live_report_spec(self, spec: TurnSpec) -> TurnSpec:
|
|
336
|
+
prompt_path = await prepare_live_report(SubprocessRunner(), spec)
|
|
337
|
+
return _with_prompt(spec, str(prompt_path))
|
|
338
|
+
|
|
339
|
+
@pytest.mark.asyncio
|
|
340
|
+
async def test_live_usage_reports_session_tokens(self, tmp_path: Path) -> None:
|
|
341
|
+
self._require_live()
|
|
342
|
+
spec = self.build_spec(tmp_path, resume_session_id=None)
|
|
343
|
+
await init_scratch_repo(SubprocessRunner(), spec.cwd)
|
|
344
|
+
signals = await self._run_turn(spec)
|
|
345
|
+
usage = [signal.usage for signal in signals if isinstance(signal, UsageReported)]
|
|
346
|
+
_expect(bool(usage), "live turn reported no usage")
|
|
347
|
+
_expect(usage[-1].input_tokens + usage[-1].output_tokens > 0, "live token usage was zero")
|
|
348
|
+
|
|
349
|
+
@pytest.mark.asyncio
|
|
350
|
+
async def test_live_model_change_resumes_and_recalls_session_memory(
|
|
351
|
+
self, tmp_path: Path
|
|
352
|
+
) -> None:
|
|
353
|
+
self._require_live()
|
|
354
|
+
first = self.build_spec(tmp_path, resume_session_id=None)
|
|
355
|
+
await init_scratch_repo(SubprocessRunner(), first.cwd)
|
|
356
|
+
nonce = uuid.uuid4().hex
|
|
357
|
+
first_signals = await self._run_turn(
|
|
358
|
+
_with_prompt(first, RESUME_NONCE_PROMPT.format(nonce=nonce))
|
|
359
|
+
)
|
|
360
|
+
session_id, usage = _latest_usage(first_signals)
|
|
361
|
+
resumed = self.build_spec(
|
|
362
|
+
tmp_path,
|
|
363
|
+
resume_session_id=session_id,
|
|
364
|
+
usage=usage,
|
|
365
|
+
model=f"{first.rung.model}-changed",
|
|
366
|
+
)
|
|
367
|
+
signals = await self._run_turn(_with_prompt(resumed, RESUME_RECALL_PROMPT))
|
|
368
|
+
message = "\n".join(signal.text for signal in signals if isinstance(signal, Message))
|
|
369
|
+
_expect(nonce in message, "resumed turn did not recall the nonce")
|
|
370
|
+
|
|
371
|
+
@pytest.mark.asyncio
|
|
372
|
+
async def test_live_stop_leaves_session_resumable(self, tmp_path: Path) -> None:
|
|
373
|
+
self._require_live()
|
|
374
|
+
spec = self.build_spec(tmp_path, resume_session_id=None)
|
|
375
|
+
await init_scratch_repo(SubprocessRunner(), spec.cwd)
|
|
376
|
+
agent = self._build_agent()
|
|
377
|
+
turn = await agent.start_turn(_with_prompt(spec, "wait for the next instruction"))
|
|
378
|
+
signals = turn.signals()
|
|
379
|
+
started = await asyncio.wait_for(signals.__anext__(), _TURN_TIMEOUT_SECONDS)
|
|
380
|
+
if not isinstance(started, SessionStarted):
|
|
381
|
+
message = "live turn did not report a resumable session id"
|
|
382
|
+
raise TypeError(message)
|
|
383
|
+
await turn.stop()
|
|
384
|
+
resumed = self.build_spec(tmp_path, resume_session_id=started.session_id)
|
|
385
|
+
resumed_signals = await self._run_turn(resumed)
|
|
386
|
+
_expect(isinstance(resumed_signals[-1], TurnEnded), "resumed turn did not end")
|
|
387
|
+
|
|
388
|
+
@pytest.mark.asyncio
|
|
389
|
+
async def test_live_headless_ask_returns_schema_valid_json(self, tmp_path: Path) -> None:
|
|
390
|
+
self._require_live()
|
|
391
|
+
schema_model = self.headless_ask_schema_model()
|
|
392
|
+
if schema_model is None:
|
|
393
|
+
pytest.skip("plugin does not provide HeadlessAgent")
|
|
394
|
+
spec = self.build_spec(tmp_path, resume_session_id=None)
|
|
395
|
+
agent = self._build_agent()
|
|
396
|
+
headless = agent.headless()
|
|
397
|
+
if headless is None:
|
|
398
|
+
pytest.skip("plugin does not provide HeadlessAgent")
|
|
399
|
+
result = await headless.ask(
|
|
400
|
+
self.headless_ask_prompt(),
|
|
401
|
+
schema_model.model_json_schema(),
|
|
402
|
+
self.headless_ask_model(),
|
|
403
|
+
spec.rung.effort,
|
|
404
|
+
spec.cwd,
|
|
405
|
+
timedelta(seconds=_TURN_TIMEOUT_SECONDS),
|
|
406
|
+
)
|
|
407
|
+
schema_model.model_validate(result)
|
|
408
|
+
|
|
409
|
+
@pytest.mark.asyncio
|
|
410
|
+
@pytest.mark.timeout(_LIVE_COMPACTION_TIMEOUT_SECONDS + _PYTEST_TIMEOUT_MARGIN_SECONDS)
|
|
411
|
+
async def test_live_turn_compacts_as_it_grows_and_completes(self, tmp_path: Path) -> None:
|
|
412
|
+
self._require_live()
|
|
413
|
+
spec = await self._prepare_live_report_spec(
|
|
414
|
+
self.build_spec(tmp_path, resume_session_id=None)
|
|
415
|
+
)
|
|
416
|
+
prompt_path = Path(spec.prompt)
|
|
417
|
+
await asyncio.to_thread(_write_growth_files, spec.cwd)
|
|
418
|
+
await asyncio.to_thread(prompt_path.write_text, _LIVE_COMPACTION_PROMPT)
|
|
419
|
+
nonce = uuid.uuid4().hex
|
|
420
|
+
spec = _with_prompt(
|
|
421
|
+
spec, _LIVE_COMPACTION_TURN_PROMPT.format(nonce=nonce, prompt_path=prompt_path)
|
|
422
|
+
)
|
|
423
|
+
signals = await self._run_turn(
|
|
424
|
+
spec, self.live_compaction_options(), _LIVE_COMPACTION_TIMEOUT_SECONDS
|
|
425
|
+
)
|
|
426
|
+
problem = await asyncio.to_thread(live_report_problem, spec, signals)
|
|
427
|
+
_expect(problem is None, problem or "")
|
|
428
|
+
_expect(
|
|
429
|
+
any(isinstance(signal, Compacted) for signal in signals), "the turn never compacted"
|
|
430
|
+
)
|
|
431
|
+
message = "\n".join(signal.text for signal in signals if isinstance(signal, Message))
|
|
432
|
+
_expect(nonce in message, "the compacted turn forgot the nonce")
|
|
433
|
+
|
|
434
|
+
def _require_live(self) -> None:
|
|
435
|
+
if not self.runs_live_checks_in_default_gate and not os.environ.get(E2E_ENV_VAR):
|
|
436
|
+
pytest.skip(f"set {E2E_ENV_VAR}=1 to run live agent contract checks")
|
|
437
|
+
|
|
438
|
+
def _build_agent(self, options: Mapping[str, Any] | None = None) -> Agent:
|
|
439
|
+
return self.agent_factory()(
|
|
440
|
+
AgentContext(
|
|
441
|
+
options=self.agent_options() if options is None else options,
|
|
442
|
+
runner=SubprocessRunner(),
|
|
443
|
+
clock=SystemClock(),
|
|
444
|
+
)
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
async def _run_turn(
|
|
448
|
+
self,
|
|
449
|
+
spec: TurnSpec,
|
|
450
|
+
options: Mapping[str, Any] | None = None,
|
|
451
|
+
timeout: float = _TURN_TIMEOUT_SECONDS,
|
|
452
|
+
) -> list[AgentSignal]:
|
|
453
|
+
turn = await self._build_agent(options).start_turn(spec)
|
|
454
|
+
async with asyncio.timeout(timeout):
|
|
455
|
+
return [signal async for signal in turn.signals()]
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _replay(parser: TurnParser, sample: RecordedStream) -> list[AgentSignal]:
|
|
459
|
+
signals: list[AgentSignal] = []
|
|
460
|
+
for line in sample.stdout.read_text().splitlines():
|
|
461
|
+
signals.extend(parser.feed(line))
|
|
462
|
+
signals.extend(parser.finish(sample.exit_code, sample.stderr.read_text()))
|
|
463
|
+
return signals
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _process_outcome(parser: TurnParser, sample: RecordedStream) -> TurnProcessResult:
|
|
467
|
+
return TurnProcessResult(
|
|
468
|
+
signals=tuple(_replay(parser, sample)),
|
|
469
|
+
exit_code=sample.exit_code,
|
|
470
|
+
stderr=sample.stderr.read_text(),
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def assert_agent_obligations(signals: Sequence[AgentSignal], session_id: str | None) -> None:
|
|
475
|
+
"""Validate the stream-order, usage and tool-call obligations."""
|
|
476
|
+
_validate_session_start(signals, session_id)
|
|
477
|
+
_validate_terminal(signals)
|
|
478
|
+
_validate_tool_sequences(signals)
|
|
479
|
+
_validate_usage_sequences(signals)
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _validate_session_start(signals: Sequence[AgentSignal], session_id: str | None) -> None:
|
|
483
|
+
starts = [
|
|
484
|
+
(index, signal)
|
|
485
|
+
for index, signal in enumerate(signals)
|
|
486
|
+
if isinstance(signal, SessionStarted)
|
|
487
|
+
]
|
|
488
|
+
_expect(len(starts) == 1, "turn must emit one SessionStarted")
|
|
489
|
+
_expect(starts[0][0] == 0, "SessionStarted must be the first signal")
|
|
490
|
+
started = starts[0][1]
|
|
491
|
+
_expect(bool(started.session_id), "SessionStarted must carry a session id")
|
|
492
|
+
if session_id is not None:
|
|
493
|
+
_expect(started.session_id == session_id, "resumed turn changed its session id")
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _validate_terminal(signals: Sequence[AgentSignal]) -> None:
|
|
497
|
+
terminals = [signal for signal in signals if isinstance(signal, (TurnEnded, TurnFailed))]
|
|
498
|
+
_expect(len(terminals) == 1, "turn must emit one terminal signal")
|
|
499
|
+
_expect(signals[-1] == terminals[0], "terminal signal must be last")
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _validate_tool_sequences(signals: Sequence[AgentSignal]) -> None:
|
|
503
|
+
running_tools: set[str] = set()
|
|
504
|
+
for signal in signals:
|
|
505
|
+
if isinstance(signal, ToolStarted):
|
|
506
|
+
_expect(signal.name not in running_tools, "tool started while already running")
|
|
507
|
+
running_tools.add(signal.name)
|
|
508
|
+
elif isinstance(signal, ToolFinished):
|
|
509
|
+
_expect(signal.name in running_tools, "tool finished without a matching start")
|
|
510
|
+
running_tools.remove(signal.name)
|
|
511
|
+
_expect(not running_tools, "turn ended while a tool was still running")
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def _validate_usage_sequences(signals: Sequence[AgentSignal]) -> None:
|
|
515
|
+
last_usage: Usage | None = None
|
|
516
|
+
compacted_since_usage = False
|
|
517
|
+
for signal in signals:
|
|
518
|
+
if isinstance(signal, Compacted):
|
|
519
|
+
compacted_since_usage = True
|
|
520
|
+
elif isinstance(signal, UsageReported):
|
|
521
|
+
if last_usage is not None:
|
|
522
|
+
_expect(
|
|
523
|
+
signal.usage.input_tokens >= last_usage.input_tokens, "input tokens decreased"
|
|
524
|
+
)
|
|
525
|
+
_expect(
|
|
526
|
+
signal.usage.output_tokens >= last_usage.output_tokens,
|
|
527
|
+
"output tokens decreased",
|
|
528
|
+
)
|
|
529
|
+
_expect(
|
|
530
|
+
signal.usage.cache_read_tokens >= last_usage.cache_read_tokens,
|
|
531
|
+
"cache-read tokens decreased",
|
|
532
|
+
)
|
|
533
|
+
_expect(
|
|
534
|
+
signal.usage.cache_write_tokens >= last_usage.cache_write_tokens,
|
|
535
|
+
"cache-write tokens decreased",
|
|
536
|
+
)
|
|
537
|
+
if (
|
|
538
|
+
compacted_since_usage
|
|
539
|
+
and last_usage.context_used_pct is not None
|
|
540
|
+
and signal.usage.context_used_pct is not None
|
|
541
|
+
):
|
|
542
|
+
_expect(
|
|
543
|
+
signal.usage.context_used_pct < last_usage.context_used_pct,
|
|
544
|
+
"context use did not drop after compaction",
|
|
545
|
+
)
|
|
546
|
+
if signal.usage.cost_usd is None:
|
|
547
|
+
_expect(
|
|
548
|
+
signal.usage.cost_basis == COST_BASIS_UNKNOWN, "unpriced usage must say unknown"
|
|
549
|
+
)
|
|
550
|
+
else:
|
|
551
|
+
_expect(
|
|
552
|
+
signal.usage.cost_basis != COST_BASIS_UNKNOWN, "priced usage needs a cost basis"
|
|
553
|
+
)
|
|
554
|
+
last_usage = signal.usage
|
|
555
|
+
compacted_since_usage = False
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _expect(condition: bool, message: str) -> None:
|
|
559
|
+
if not condition:
|
|
560
|
+
raise AssertionError(message)
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def _with_prompt(spec: TurnSpec, prompt: str) -> TurnSpec:
|
|
564
|
+
return TurnSpec(
|
|
565
|
+
rung=spec.rung,
|
|
566
|
+
role=spec.role,
|
|
567
|
+
prompt=prompt,
|
|
568
|
+
cwd=spec.cwd,
|
|
569
|
+
run_dir=spec.run_dir,
|
|
570
|
+
turn_dir=spec.turn_dir,
|
|
571
|
+
add_dirs=spec.add_dirs,
|
|
572
|
+
env=spec.env,
|
|
573
|
+
session_id=spec.session_id,
|
|
574
|
+
usage=spec.usage,
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def _latest_usage(signals: Sequence[AgentSignal]) -> tuple[str, Usage]:
|
|
579
|
+
session = next(signal for signal in signals if isinstance(signal, SessionStarted))
|
|
580
|
+
usage = [signal.usage for signal in signals if isinstance(signal, UsageReported)]
|
|
581
|
+
_expect(bool(usage), "live turn reported no usage")
|
|
582
|
+
return session.session_id, usage[-1]
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def _ended(signals: Sequence[AgentSignal]) -> bool:
|
|
586
|
+
return isinstance(signals[-1], TurnEnded)
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
_COMPACTION_RULES: dict[CompactionCase, Callable[[Sequence[AgentSignal]], bool]] = {
|
|
590
|
+
CompactionCase.THRESHOLD: lambda signals: (
|
|
591
|
+
_ended(signals) and sum(isinstance(signal, Compacted) for signal in signals) > 1
|
|
592
|
+
),
|
|
593
|
+
CompactionCase.CONTEXT_FAILURE: lambda signals: (
|
|
594
|
+
_ended(signals) and any(isinstance(signal, Compacted) for signal in signals)
|
|
595
|
+
),
|
|
596
|
+
CompactionCase.FAILED_RECOVERY: lambda signals: (
|
|
597
|
+
isinstance(signals[-1], TurnFailed) and signals[-1].kind is FailureKind.OTHER
|
|
598
|
+
),
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
class _ReplaySequence:
|
|
603
|
+
"""Runs the plugin's sequence, replacing each process with its recorded output."""
|
|
604
|
+
|
|
605
|
+
def __init__(self, inner: TurnProcessSequence, processes: Sequence[ReplayedProcess]) -> None:
|
|
606
|
+
self._inner = inner
|
|
607
|
+
self._processes = list(processes)
|
|
608
|
+
self._next = 0
|
|
609
|
+
self.argv: list[tuple[str, ...]] = []
|
|
610
|
+
|
|
611
|
+
@property
|
|
612
|
+
def exhausted(self) -> bool:
|
|
613
|
+
return self._next == len(self._processes)
|
|
614
|
+
|
|
615
|
+
def start(self, spec: TurnSpec) -> TurnProcess:
|
|
616
|
+
return self._replay(self._inner.start(spec))
|
|
617
|
+
|
|
618
|
+
def advance(self, spec: TurnSpec, previous: TurnProcessResult) -> TurnProcess | None:
|
|
619
|
+
process = self._inner.advance(spec, previous)
|
|
620
|
+
return None if process is None else self._replay(process)
|
|
621
|
+
|
|
622
|
+
def _replay(self, process: TurnProcess) -> TurnProcess:
|
|
623
|
+
_expect(self._next < len(self._processes), f"unrecorded process {process.argv}")
|
|
624
|
+
recorded = self._processes[self._next]
|
|
625
|
+
self._next += 1
|
|
626
|
+
self.argv.append(process.argv)
|
|
627
|
+
plan = {
|
|
628
|
+
"writes": {str(path): content for path, content in recorded.writes.items()},
|
|
629
|
+
"stdout": str(recorded.stream.stdout),
|
|
630
|
+
"stderr": str(recorded.stream.stderr),
|
|
631
|
+
"exit_code": recorded.stream.exit_code,
|
|
632
|
+
}
|
|
633
|
+
return TurnProcess(
|
|
634
|
+
argv=(sys.executable, _COMMAND_FLAG, _REPLAY_SCRIPT, json.dumps(plan)),
|
|
635
|
+
cwd=process.cwd,
|
|
636
|
+
env=process.env,
|
|
637
|
+
parser=process.parser,
|
|
638
|
+
)
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def _write_growth_files(repo: Path) -> None:
|
|
642
|
+
for number in range(1, _GROWTH_FILES + 1):
|
|
643
|
+
lines = (
|
|
644
|
+
f"{number}-{line:04d} "
|
|
645
|
+
+ " ".join(_growth_word(number, line, word) for word in range(_GROWTH_WORDS_PER_LINE))
|
|
646
|
+
for line in range(1, _GROWTH_FILE_LINES + 1)
|
|
647
|
+
)
|
|
648
|
+
(repo / f"part{number}.txt").write_text("\n".join(lines) + "\n")
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def _growth_word(*key: int) -> str:
|
|
652
|
+
digest = hashlib.blake2b(repr(key).encode(), digest_size=_SYLLABLES_PER_WORD).digest()
|
|
653
|
+
return "".join(_SYLLABLES[byte % len(_SYLLABLES)] for byte in digest)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""The resume-recall protocol exercised by `AgentContractSuite`'s live check 4.
|
|
2
|
+
|
|
3
|
+
A plugin whose agent has no memory of its own (only the fake agent needs this) must answer
|
|
4
|
+
`RESUME_RECALL_PROMPT` with a message that contains whatever nonce it was given via
|
|
5
|
+
`RESUME_NONCE_PROMPT` earlier in the *same* session, recovered from state tied to that
|
|
6
|
+
session's id rather than to the pane or run directory alone. A real, conversational agent
|
|
7
|
+
needs no special handling: it answers from genuine memory.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
RESUME_NONCE_PROMPT = "Remember this nonce for later in this session: {nonce}"
|
|
13
|
+
RESUME_RECALL_PROMPT = (
|
|
14
|
+
"What nonce did I ask you to remember earlier in this session? Reply with only the nonce."
|
|
15
|
+
)
|