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,17 @@
|
|
|
1
|
+
"""Reusable process and stream tools for agent plugins."""
|
|
2
|
+
|
|
3
|
+
from shikiru.agents.sdk.turn import (
|
|
4
|
+
StreamingTurn,
|
|
5
|
+
TurnParser,
|
|
6
|
+
TurnProcess,
|
|
7
|
+
TurnProcessResult,
|
|
8
|
+
TurnProcessSequence,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"StreamingTurn",
|
|
13
|
+
"TurnParser",
|
|
14
|
+
"TurnProcess",
|
|
15
|
+
"TurnProcessResult",
|
|
16
|
+
"TurnProcessSequence",
|
|
17
|
+
]
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""`ResetTimeParser`: turns an agent's reset-time strings into aware `datetime`s."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from datetime import UTC, datetime, timedelta
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
|
|
13
|
+
from shikiru.clock import Clock
|
|
14
|
+
|
|
15
|
+
_EPOCH_SECONDS_PATTERN = re.compile(r"^\d+(\.\d+)?$")
|
|
16
|
+
_CLOCK_TIME_PATTERN = re.compile(
|
|
17
|
+
r"resets\s+(?P<hour>\d{1,2})(?::(?P<minute>\d{2}))?\s*(?P<meridiem>am|pm)\s*\((?P<zone>[^)]+)\)",
|
|
18
|
+
re.IGNORECASE,
|
|
19
|
+
)
|
|
20
|
+
_RELATIVE_DURATION_COMPONENT = r"(?P<amount>\d+)\s*(?P<unit>second|minute|hour|day)s?"
|
|
21
|
+
_RELATIVE_DURATION_PATTERN = re.compile(
|
|
22
|
+
rf"in\s+(?P<components>(?:{_RELATIVE_DURATION_COMPONENT}\s*)+)", re.IGNORECASE
|
|
23
|
+
)
|
|
24
|
+
_RELATIVE_DURATION_COMPONENT_PATTERN = re.compile(_RELATIVE_DURATION_COMPONENT, re.IGNORECASE)
|
|
25
|
+
_DATE_TEXT_PATTERN = re.compile(
|
|
26
|
+
r"(?P<month>[A-Za-z]{3})[a-z]*\.?\s+(?P<day>\d{1,2})(?:st|nd|rd|th)?,?\s+(?P<year>\d{4})"
|
|
27
|
+
r"(?:\s+at)?\s+(?P<hour>\d{1,2}):(?P<minute>\d{2})\s*(?P<meridiem>am|pm)"
|
|
28
|
+
r"(?:\s*\((?P<zone>[^)]+)\)|\s+(?P<utc>UTC|GMT)\b)?",
|
|
29
|
+
re.IGNORECASE,
|
|
30
|
+
)
|
|
31
|
+
_MONTHS = ("jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec")
|
|
32
|
+
_PM = "pm"
|
|
33
|
+
_SECONDS_PER_UNIT = {"second": 1, "minute": 60, "hour": 3600, "day": 86400}
|
|
34
|
+
_HOURS_PER_MERIDIEM = 12
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ResetTimeParser:
|
|
38
|
+
"""Parses a `resets ...`, ISO, epoch-seconds or relative-duration string into a `datetime`."""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self, clock: Clock, *, resolve_local: Callable[[datetime], datetime] | None = None
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Anchor relative and clock-time parsing on `clock.now()`, never the wall clock.
|
|
44
|
+
|
|
45
|
+
`resolve_local` turns a naive wall-clock time into the aware instant it names in the
|
|
46
|
+
zone the agent printed it in. Without it a date text that names no zone stays unknown.
|
|
47
|
+
"""
|
|
48
|
+
self._clock = clock
|
|
49
|
+
self._resolve_local = resolve_local
|
|
50
|
+
|
|
51
|
+
def parse(self, text: str) -> datetime | None:
|
|
52
|
+
"""Return the aware `datetime` `text` names, or `None` if no known format matches."""
|
|
53
|
+
stripped = text.strip()
|
|
54
|
+
return (
|
|
55
|
+
self._parse_epoch(stripped)
|
|
56
|
+
or self._parse_iso(stripped)
|
|
57
|
+
or self._parse_relative(stripped)
|
|
58
|
+
or self._parse_clock_time(stripped)
|
|
59
|
+
or self._parse_date_text(stripped)
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
def _parse_epoch(self, text: str) -> datetime | None:
|
|
63
|
+
if not _EPOCH_SECONDS_PATTERN.match(text):
|
|
64
|
+
return None
|
|
65
|
+
return datetime.fromtimestamp(float(text), tz=UTC)
|
|
66
|
+
|
|
67
|
+
def _parse_iso(self, text: str) -> datetime | None:
|
|
68
|
+
try:
|
|
69
|
+
parsed = datetime.fromisoformat(text)
|
|
70
|
+
except ValueError:
|
|
71
|
+
return None
|
|
72
|
+
return parsed if parsed.tzinfo is not None else parsed.replace(tzinfo=UTC)
|
|
73
|
+
|
|
74
|
+
def _parse_relative(self, text: str) -> datetime | None:
|
|
75
|
+
match = _RELATIVE_DURATION_PATTERN.search(text)
|
|
76
|
+
if match is None:
|
|
77
|
+
return None
|
|
78
|
+
seconds = sum(
|
|
79
|
+
int(component.group("amount")) * _SECONDS_PER_UNIT[component.group("unit").lower()]
|
|
80
|
+
for component in _RELATIVE_DURATION_COMPONENT_PATTERN.finditer(
|
|
81
|
+
match.group("components")
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
return self._clock.now() + timedelta(seconds=seconds)
|
|
85
|
+
|
|
86
|
+
def _parse_clock_time(self, text: str) -> datetime | None:
|
|
87
|
+
match = _CLOCK_TIME_PATTERN.search(text)
|
|
88
|
+
if match is None:
|
|
89
|
+
return None
|
|
90
|
+
try:
|
|
91
|
+
zone = ZoneInfo(match.group("zone"))
|
|
92
|
+
except ZoneInfoNotFoundError, ValueError:
|
|
93
|
+
return None
|
|
94
|
+
hour = int(match.group("hour")) % _HOURS_PER_MERIDIEM
|
|
95
|
+
if match.group("meridiem").lower() == _PM:
|
|
96
|
+
hour += _HOURS_PER_MERIDIEM
|
|
97
|
+
minute = int(match.group("minute") or 0)
|
|
98
|
+
now_in_zone = self._clock.now().astimezone(zone)
|
|
99
|
+
candidate = now_in_zone.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
|
100
|
+
if candidate <= now_in_zone:
|
|
101
|
+
candidate += timedelta(days=1)
|
|
102
|
+
return candidate
|
|
103
|
+
|
|
104
|
+
def _parse_date_text(self, text: str) -> datetime | None:
|
|
105
|
+
match = _DATE_TEXT_PATTERN.search(text)
|
|
106
|
+
if match is None:
|
|
107
|
+
return None
|
|
108
|
+
month = match.group("month").lower()
|
|
109
|
+
if month not in _MONTHS:
|
|
110
|
+
return None
|
|
111
|
+
hour = int(match.group("hour"))
|
|
112
|
+
if not 1 <= hour <= _HOURS_PER_MERIDIEM:
|
|
113
|
+
return None
|
|
114
|
+
hour = hour % _HOURS_PER_MERIDIEM
|
|
115
|
+
if match.group("meridiem").lower() == _PM:
|
|
116
|
+
hour += _HOURS_PER_MERIDIEM
|
|
117
|
+
try:
|
|
118
|
+
naive = datetime( # noqa: DTZ001
|
|
119
|
+
int(match.group("year")),
|
|
120
|
+
_MONTHS.index(month) + 1,
|
|
121
|
+
int(match.group("day")),
|
|
122
|
+
hour,
|
|
123
|
+
int(match.group("minute")),
|
|
124
|
+
)
|
|
125
|
+
except ValueError:
|
|
126
|
+
return None
|
|
127
|
+
return self._attach_zone(naive, match.group("zone"), match.group("utc"))
|
|
128
|
+
|
|
129
|
+
def _attach_zone(self, naive: datetime, zone: str | None, utc: str | None) -> datetime | None:
|
|
130
|
+
if utc is not None:
|
|
131
|
+
return naive.replace(tzinfo=UTC)
|
|
132
|
+
if zone is not None:
|
|
133
|
+
try:
|
|
134
|
+
return naive.replace(tzinfo=ZoneInfo(zone.strip()))
|
|
135
|
+
except ZoneInfoNotFoundError, ValueError:
|
|
136
|
+
return None
|
|
137
|
+
return self._resolve_local(naive) if self._resolve_local is not None else None
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"""Process-per-turn plugin building blocks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import TYPE_CHECKING, Protocol
|
|
7
|
+
|
|
8
|
+
from shikiru.agents.contracts import (
|
|
9
|
+
AgentSignal,
|
|
10
|
+
FailureKind,
|
|
11
|
+
SessionStarted,
|
|
12
|
+
TurnEnded,
|
|
13
|
+
TurnFailed,
|
|
14
|
+
TurnSpec,
|
|
15
|
+
)
|
|
16
|
+
from shikiru.private import append_private_bytes, make_private_dir
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from collections.abc import AsyncIterator, Callable, Mapping
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from shikiru.process import SpawnedProcess, SubprocessRunner
|
|
23
|
+
|
|
24
|
+
STREAM_FILENAME_SUFFIX = ".stream.jsonl"
|
|
25
|
+
STDERR_FILENAME_SUFFIX = ".stderr.log"
|
|
26
|
+
_LINE_ENDINGS = "\r\n"
|
|
27
|
+
EMPTY_OUTPUT = "(empty)"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def stream_log_path(turn_dir: Path, role: str) -> Path:
|
|
31
|
+
"""Return where a turn keeps `role`'s raw stdout stream."""
|
|
32
|
+
return turn_dir / f"{role}{STREAM_FILENAME_SUFFIX}"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def stderr_log_path(turn_dir: Path, role: str) -> Path:
|
|
36
|
+
"""Return where a turn keeps `role`'s captured stderr."""
|
|
37
|
+
return turn_dir / f"{role}{STDERR_FILENAME_SUFFIX}"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _append_raw(path: Path, content: bytes) -> None:
|
|
41
|
+
make_private_dir(path.parent)
|
|
42
|
+
append_private_bytes(path, content)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class TurnParser(Protocol):
|
|
46
|
+
"""Maps one agent's stdout lines and process exit into normalized signals."""
|
|
47
|
+
|
|
48
|
+
def feed(self, line: str) -> list[AgentSignal]:
|
|
49
|
+
"""Map one newline-free stdout line to zero or more signals."""
|
|
50
|
+
...
|
|
51
|
+
|
|
52
|
+
def finish(
|
|
53
|
+
self, exit_code: int, stderr: str, *, interrupted: bool = False
|
|
54
|
+
) -> list[AgentSignal]:
|
|
55
|
+
"""Map process exit and captured stderr, marking an intentional stop as interrupted."""
|
|
56
|
+
...
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class TurnProcess:
|
|
61
|
+
"""One process in a plugin's compact, prompt or retry sequence."""
|
|
62
|
+
|
|
63
|
+
argv: tuple[str, ...]
|
|
64
|
+
cwd: Path
|
|
65
|
+
env: Mapping[str, str]
|
|
66
|
+
parser: TurnParser
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True)
|
|
70
|
+
class TurnProcessResult:
|
|
71
|
+
"""The parsed outcome of one process in a turn sequence."""
|
|
72
|
+
|
|
73
|
+
signals: tuple[AgentSignal, ...]
|
|
74
|
+
exit_code: int
|
|
75
|
+
stderr: str
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class TurnProcessSequence(Protocol):
|
|
79
|
+
"""Builds the next process in one turn, using the previous process outcome."""
|
|
80
|
+
|
|
81
|
+
def start(self, spec: TurnSpec) -> TurnProcess:
|
|
82
|
+
"""Build the first process, including pre-turn compaction when needed."""
|
|
83
|
+
...
|
|
84
|
+
|
|
85
|
+
def advance(self, spec: TurnSpec, previous: TurnProcessResult) -> TurnProcess | None:
|
|
86
|
+
"""Build a compact or retry process, or finish the turn with `None`."""
|
|
87
|
+
...
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class StreamingTurn:
|
|
91
|
+
"""Runs a plugin's process sequence and validates the turn's start and final signal."""
|
|
92
|
+
|
|
93
|
+
def __init__(
|
|
94
|
+
self,
|
|
95
|
+
spec: TurnSpec,
|
|
96
|
+
runner: SubprocessRunner,
|
|
97
|
+
sequence: TurnProcessSequence,
|
|
98
|
+
first_process: TurnProcess,
|
|
99
|
+
process: SpawnedProcess,
|
|
100
|
+
) -> None:
|
|
101
|
+
self._spec = spec
|
|
102
|
+
self._runner = runner
|
|
103
|
+
self._sequence = sequence
|
|
104
|
+
self._current_process_spec = first_process
|
|
105
|
+
self._process = process
|
|
106
|
+
self._signal_reader_started = False
|
|
107
|
+
self._stopped = False
|
|
108
|
+
self._interrupted = False
|
|
109
|
+
self._session_id: str | None = None
|
|
110
|
+
self._session_started = False
|
|
111
|
+
self._process_session_started = False
|
|
112
|
+
self._signal_count = 0
|
|
113
|
+
|
|
114
|
+
@classmethod
|
|
115
|
+
async def start(
|
|
116
|
+
cls, spec: TurnSpec, runner: SubprocessRunner, sequence: TurnProcessSequence
|
|
117
|
+
) -> StreamingTurn:
|
|
118
|
+
"""Create the first child process and return a live `Turn`."""
|
|
119
|
+
first_process = sequence.start(spec)
|
|
120
|
+
process = await runner.spawn(
|
|
121
|
+
first_process.argv,
|
|
122
|
+
cwd=first_process.cwd,
|
|
123
|
+
env=first_process.env,
|
|
124
|
+
on_started=spec.on_process,
|
|
125
|
+
)
|
|
126
|
+
return cls(spec, runner, sequence, first_process, process)
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def pid(self) -> int:
|
|
130
|
+
"""Return the operating system id for the process running now."""
|
|
131
|
+
return self._process.pid
|
|
132
|
+
|
|
133
|
+
async def signals(self) -> AsyncIterator[AgentSignal]:
|
|
134
|
+
"""Yield normalized signals once, preserving each process's raw output in the turn dir."""
|
|
135
|
+
if self._signal_reader_started:
|
|
136
|
+
message = "signals() can only be consumed once"
|
|
137
|
+
raise RuntimeError(message)
|
|
138
|
+
self._signal_reader_started = True
|
|
139
|
+
process = self._process
|
|
140
|
+
try:
|
|
141
|
+
while True:
|
|
142
|
+
self._process_session_started = False
|
|
143
|
+
execution = _ProcessExecution(
|
|
144
|
+
parser=self._current_process_spec.parser,
|
|
145
|
+
stream_path=self._stream_path,
|
|
146
|
+
stderr_path=self._stderr_path,
|
|
147
|
+
normalize=self._normalize,
|
|
148
|
+
process=process,
|
|
149
|
+
interrupted=lambda: self._interrupted,
|
|
150
|
+
)
|
|
151
|
+
async for signal in execution.signals():
|
|
152
|
+
yield signal
|
|
153
|
+
outcome = execution.outcome
|
|
154
|
+
protocol_failure = self._protocol_failure(execution, outcome, process)
|
|
155
|
+
if protocol_failure is not None:
|
|
156
|
+
yield protocol_failure
|
|
157
|
+
return
|
|
158
|
+
if not self._session_started or self._stopped:
|
|
159
|
+
terminal = self._stopped_or_start_failure(execution, outcome, process)
|
|
160
|
+
if terminal is not None:
|
|
161
|
+
yield terminal
|
|
162
|
+
return
|
|
163
|
+
next_process = self._sequence.advance(self._spec, outcome)
|
|
164
|
+
if next_process is None:
|
|
165
|
+
terminal = execution.terminal or self._unknown_failure(
|
|
166
|
+
"agent process ended without a turn result",
|
|
167
|
+
outcome.exit_code,
|
|
168
|
+
process.stderr_tail,
|
|
169
|
+
)
|
|
170
|
+
yield terminal
|
|
171
|
+
return
|
|
172
|
+
process = await self._runner.spawn(
|
|
173
|
+
next_process.argv,
|
|
174
|
+
cwd=next_process.cwd,
|
|
175
|
+
env=next_process.env,
|
|
176
|
+
on_started=self._spec.on_process,
|
|
177
|
+
)
|
|
178
|
+
self._current_process_spec = next_process
|
|
179
|
+
self._process = process
|
|
180
|
+
finally:
|
|
181
|
+
if process.returncode is None:
|
|
182
|
+
await process.stop()
|
|
183
|
+
|
|
184
|
+
async def stop(self) -> None:
|
|
185
|
+
"""Stop the process running now, allowing a later turn to resume its session."""
|
|
186
|
+
self._stopped = True
|
|
187
|
+
self._interrupted = self._interrupted or self._process.returncode is None
|
|
188
|
+
await self._process.stop()
|
|
189
|
+
|
|
190
|
+
@classmethod
|
|
191
|
+
def _unknown_failure(cls, reason: str, exit_code: int, stderr_tail: str) -> TurnFailed:
|
|
192
|
+
return TurnFailed(
|
|
193
|
+
kind=FailureKind.UNKNOWN,
|
|
194
|
+
message=cls._unknown_message(reason, exit_code, stderr_tail),
|
|
195
|
+
resets_at=None,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
def _protocol_failure(
|
|
199
|
+
self, execution: _ProcessExecution, outcome: TurnProcessResult, process: SpawnedProcess
|
|
200
|
+
) -> TurnFailed | None:
|
|
201
|
+
if execution.protocol_problem is None:
|
|
202
|
+
return None
|
|
203
|
+
return self._unknown_failure(
|
|
204
|
+
execution.protocol_problem, outcome.exit_code, process.stderr_tail
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
def _stopped_or_start_failure(
|
|
208
|
+
self, execution: _ProcessExecution, outcome: TurnProcessResult, process: SpawnedProcess
|
|
209
|
+
) -> TurnEnded | TurnFailed | None:
|
|
210
|
+
if not self._session_started:
|
|
211
|
+
if self._interrupted and not outcome.signals:
|
|
212
|
+
return None
|
|
213
|
+
if isinstance(execution.terminal, TurnFailed):
|
|
214
|
+
return execution.terminal
|
|
215
|
+
return self._unknown_failure(
|
|
216
|
+
"agent stream omitted SessionStarted", outcome.exit_code, process.stderr_tail
|
|
217
|
+
)
|
|
218
|
+
if execution.terminal is not None:
|
|
219
|
+
return execution.terminal
|
|
220
|
+
if self._interrupted:
|
|
221
|
+
return None
|
|
222
|
+
return self._unknown_failure(
|
|
223
|
+
"agent process ended without a turn result", outcome.exit_code, process.stderr_tail
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
def _normalize(self, signal: AgentSignal) -> tuple[AgentSignal | None, str | None]:
|
|
227
|
+
if not isinstance(signal, SessionStarted):
|
|
228
|
+
return self._normalize_other(signal)
|
|
229
|
+
if self._process_session_started:
|
|
230
|
+
return None, "process emitted more than one SessionStarted"
|
|
231
|
+
self._process_session_started = True
|
|
232
|
+
return self._normalize_session_start(signal)
|
|
233
|
+
|
|
234
|
+
def _normalize_session_start(
|
|
235
|
+
self, signal: SessionStarted
|
|
236
|
+
) -> tuple[AgentSignal | None, str | None]:
|
|
237
|
+
if self._session_started:
|
|
238
|
+
if signal.session_id != self._session_id:
|
|
239
|
+
return None, "turn emitted more than one session id"
|
|
240
|
+
return None, None
|
|
241
|
+
if self._signal_count:
|
|
242
|
+
return None, "agent stream emitted a signal before SessionStarted"
|
|
243
|
+
if not signal.session_id.strip():
|
|
244
|
+
return None, "agent stream emitted an empty session id"
|
|
245
|
+
if self._spec.session_id is not None and signal.session_id != self._spec.session_id:
|
|
246
|
+
return None, "resumed turn emitted a different session id"
|
|
247
|
+
self._session_id = signal.session_id
|
|
248
|
+
self._session_started = True
|
|
249
|
+
self._signal_count += 1
|
|
250
|
+
return signal, None
|
|
251
|
+
|
|
252
|
+
def _normalize_other(self, signal: AgentSignal) -> tuple[AgentSignal | None, str | None]:
|
|
253
|
+
if not self._session_started:
|
|
254
|
+
self._signal_count += 1
|
|
255
|
+
return None, "turn emitted a signal before SessionStarted"
|
|
256
|
+
self._signal_count += 1
|
|
257
|
+
return signal, None
|
|
258
|
+
|
|
259
|
+
@staticmethod
|
|
260
|
+
def _unknown_message(reason: str, exit_code: int, stderr_tail: str) -> str:
|
|
261
|
+
tail = stderr_tail or EMPTY_OUTPUT
|
|
262
|
+
return f"{reason}; process exited with status {exit_code}; stderr tail: {tail}"
|
|
263
|
+
|
|
264
|
+
@property
|
|
265
|
+
def _stream_path(self) -> Path:
|
|
266
|
+
return stream_log_path(self._spec.turn_dir, self._spec.role)
|
|
267
|
+
|
|
268
|
+
@property
|
|
269
|
+
def _stderr_path(self) -> Path:
|
|
270
|
+
return stderr_log_path(self._spec.turn_dir, self._spec.role)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
class _ProcessExecution:
|
|
274
|
+
def __init__(
|
|
275
|
+
self,
|
|
276
|
+
*,
|
|
277
|
+
parser: TurnParser,
|
|
278
|
+
stream_path: Path,
|
|
279
|
+
stderr_path: Path,
|
|
280
|
+
normalize: Callable[[AgentSignal], tuple[AgentSignal | None, str | None]],
|
|
281
|
+
process: SpawnedProcess,
|
|
282
|
+
interrupted: Callable[[], bool],
|
|
283
|
+
) -> None:
|
|
284
|
+
self._parser = parser
|
|
285
|
+
self._stream_path = stream_path
|
|
286
|
+
self._stderr_path = stderr_path
|
|
287
|
+
self._normalize = normalize
|
|
288
|
+
self._process = process
|
|
289
|
+
self._interrupted = interrupted
|
|
290
|
+
self._signals: list[AgentSignal] = []
|
|
291
|
+
self.terminal: TurnEnded | TurnFailed | None = None
|
|
292
|
+
self.protocol_problem: str | None = None
|
|
293
|
+
self._outcome: TurnProcessResult | None = None
|
|
294
|
+
|
|
295
|
+
@property
|
|
296
|
+
def outcome(self) -> TurnProcessResult:
|
|
297
|
+
if self._outcome is None:
|
|
298
|
+
message = "process signals must be consumed before reading the outcome"
|
|
299
|
+
raise RuntimeError(message)
|
|
300
|
+
return self._outcome
|
|
301
|
+
|
|
302
|
+
async def signals(self) -> AsyncIterator[AgentSignal]:
|
|
303
|
+
async for raw_line in self._process.stdout_lines():
|
|
304
|
+
_append_raw(self._stream_path, raw_line)
|
|
305
|
+
line = raw_line.decode(errors="replace").rstrip(_LINE_ENDINGS)
|
|
306
|
+
async for signal in self._parsed(self._parser.feed(line)):
|
|
307
|
+
yield signal
|
|
308
|
+
exit_code = await self._process.wait()
|
|
309
|
+
_append_raw(self._stderr_path, self._process.stderr_bytes)
|
|
310
|
+
async for signal in self._parsed(
|
|
311
|
+
self._parser.finish(
|
|
312
|
+
exit_code,
|
|
313
|
+
self._process.stderr,
|
|
314
|
+
interrupted=self._interrupted(),
|
|
315
|
+
)
|
|
316
|
+
):
|
|
317
|
+
yield signal
|
|
318
|
+
self._outcome = TurnProcessResult(tuple(self._signals), exit_code, self._process.stderr)
|
|
319
|
+
|
|
320
|
+
async def _parsed(self, signals: list[AgentSignal]) -> AsyncIterator[AgentSignal]:
|
|
321
|
+
for signal in signals:
|
|
322
|
+
if self.terminal is not None:
|
|
323
|
+
self.protocol_problem = self.protocol_problem or (
|
|
324
|
+
"process emitted a signal after its terminal signal"
|
|
325
|
+
)
|
|
326
|
+
self._signals.append(signal)
|
|
327
|
+
continue
|
|
328
|
+
if isinstance(signal, (TurnEnded, TurnFailed)):
|
|
329
|
+
self.terminal = signal
|
|
330
|
+
else:
|
|
331
|
+
normalized, problem = self._normalize(signal)
|
|
332
|
+
self.protocol_problem = self.protocol_problem or problem
|
|
333
|
+
if normalized is not None and self.protocol_problem is None:
|
|
334
|
+
yield normalized
|
|
335
|
+
self._signals.append(signal)
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""The optional agent capability that exposes operator-editable plugin options."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import shlex
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from enum import StrEnum, auto
|
|
9
|
+
from typing import TYPE_CHECKING, Protocol, cast
|
|
10
|
+
|
|
11
|
+
from shikiru.errors import ConfigError
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from collections.abc import Sequence
|
|
15
|
+
|
|
16
|
+
from shikiru.agents.contracts import Agent
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SettingKind(StrEnum):
|
|
20
|
+
"""How an agent option is edited and written to the project config."""
|
|
21
|
+
|
|
22
|
+
NUMBER = auto()
|
|
23
|
+
CHOICE = auto()
|
|
24
|
+
ARGS = auto()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AgentSettingError(ConfigError):
|
|
28
|
+
"""A rejected agent setting; `key` names the plugin option and `input_id` its editor."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, key: str, message: str, input_id: str = "") -> None:
|
|
31
|
+
"""Explain why the text entered for option `key` cannot be written."""
|
|
32
|
+
super().__init__(message)
|
|
33
|
+
self.key = key
|
|
34
|
+
self.input_id = input_id
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class AgentOptionsError(ConfigError):
|
|
38
|
+
"""A plugin's rejection of its options; `key` names the offending option when known."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, message: str, key: str | None = None) -> None:
|
|
41
|
+
"""Explain why a plugin refuses its options, naming option `key` when one is at fault."""
|
|
42
|
+
super().__init__(message)
|
|
43
|
+
self.key = key
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True, slots=True)
|
|
47
|
+
class AgentSetting:
|
|
48
|
+
"""One option a plugin offers the Operator, with its default, guidance and accepted values."""
|
|
49
|
+
|
|
50
|
+
key: str
|
|
51
|
+
label: str
|
|
52
|
+
help: str
|
|
53
|
+
kind: SettingKind
|
|
54
|
+
default: float | str | tuple[str, ...] | None
|
|
55
|
+
unit: str = ""
|
|
56
|
+
choices: tuple[str, ...] = ()
|
|
57
|
+
minimum: float | None = None
|
|
58
|
+
maximum: float | None = None
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def optional(self) -> bool:
|
|
62
|
+
"""Whether the Operator may leave the option unset, so it is not written."""
|
|
63
|
+
return self.kind is SettingKind.CHOICE and self.default is None
|
|
64
|
+
|
|
65
|
+
def display(self) -> str:
|
|
66
|
+
"""Return the default as the text the Operator edits."""
|
|
67
|
+
if self.default is None:
|
|
68
|
+
return ""
|
|
69
|
+
if isinstance(self.default, tuple):
|
|
70
|
+
return shlex.join(self.default)
|
|
71
|
+
if isinstance(self.default, str):
|
|
72
|
+
return self.default
|
|
73
|
+
return f"{self.default:g}"
|
|
74
|
+
|
|
75
|
+
def parse(self, raw: str) -> float | str | list[str] | None:
|
|
76
|
+
"""Return the value to write for `raw`, `None` for an unset option, or raise."""
|
|
77
|
+
if self.kind is SettingKind.NUMBER:
|
|
78
|
+
return self._number(raw)
|
|
79
|
+
if self.kind is SettingKind.CHOICE:
|
|
80
|
+
return self._choice(raw)
|
|
81
|
+
return self._args(raw)
|
|
82
|
+
|
|
83
|
+
def _number(self, raw: str) -> float:
|
|
84
|
+
try:
|
|
85
|
+
value = float(raw)
|
|
86
|
+
except ValueError:
|
|
87
|
+
value = math.nan
|
|
88
|
+
below = self.minimum is not None and value < self.minimum
|
|
89
|
+
above = self.maximum is not None and value > self.maximum
|
|
90
|
+
if not math.isfinite(value) or below or above:
|
|
91
|
+
raise AgentSettingError(self.key, f"{self.label} must be {self._number_rule()}.")
|
|
92
|
+
return value
|
|
93
|
+
|
|
94
|
+
def _number_rule(self) -> str:
|
|
95
|
+
if self.minimum is not None and self.maximum is not None:
|
|
96
|
+
return f"a number from {self.minimum:g} to {self.maximum:g}"
|
|
97
|
+
if self.minimum is not None:
|
|
98
|
+
return f"a number of at least {self.minimum:g}"
|
|
99
|
+
if self.maximum is not None:
|
|
100
|
+
return f"a number of at most {self.maximum:g}"
|
|
101
|
+
return "a number"
|
|
102
|
+
|
|
103
|
+
def _choice(self, raw: str) -> str | None:
|
|
104
|
+
text = raw.strip()
|
|
105
|
+
if not text and self.optional:
|
|
106
|
+
return None
|
|
107
|
+
if text not in self.choices:
|
|
108
|
+
options = ", ".join(self.choices)
|
|
109
|
+
message = f"{self.label} must be one of: {options}."
|
|
110
|
+
raise AgentSettingError(self.key, message)
|
|
111
|
+
return text
|
|
112
|
+
|
|
113
|
+
def _args(self, raw: str) -> list[str]:
|
|
114
|
+
try:
|
|
115
|
+
return shlex.split(raw)
|
|
116
|
+
except ValueError as error:
|
|
117
|
+
message = f"{self.label} has invalid quoting: {error}."
|
|
118
|
+
raise AgentSettingError(self.key, message) from error
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass(frozen=True, slots=True)
|
|
122
|
+
class AgentSettings:
|
|
123
|
+
"""What an agent offers for editing, or why it cannot."""
|
|
124
|
+
|
|
125
|
+
agent: str
|
|
126
|
+
settings: tuple[AgentSetting, ...] = ()
|
|
127
|
+
supported: bool = True
|
|
128
|
+
error: str | None = None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class SettingsProvider(Protocol):
|
|
132
|
+
"""Optional agent capability listing the options worth writing to the project config."""
|
|
133
|
+
|
|
134
|
+
def settings(self) -> Sequence[AgentSetting]: ...
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def settings_for(agent: Agent) -> tuple[AgentSetting, ...] | None:
|
|
138
|
+
"""Return an agent's validated settings, or `None` when it lacks the capability."""
|
|
139
|
+
provider: object = getattr(agent, "settings", None)
|
|
140
|
+
if provider is None:
|
|
141
|
+
return None
|
|
142
|
+
if not callable(provider):
|
|
143
|
+
message = f"agent {agent.name!r} returned an invalid settings capability"
|
|
144
|
+
raise ConfigError(message)
|
|
145
|
+
value = cast("Sequence[object]", provider())
|
|
146
|
+
offered = tuple(item for item in value if isinstance(item, AgentSetting))
|
|
147
|
+
keys = {item.key for item in offered}
|
|
148
|
+
if len(offered) != len(value) or len(keys) != len(offered) or not all(keys):
|
|
149
|
+
message = f"agent {agent.name!r} returned invalid or duplicate settings"
|
|
150
|
+
raise ConfigError(message)
|
|
151
|
+
return offered
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""The agent plugin contract test kit, imported only by tests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from shikiru.agents.testing.contract_suite import (
|
|
6
|
+
AgentContractSuite,
|
|
7
|
+
CompactionCase,
|
|
8
|
+
CompactionScenario,
|
|
9
|
+
InvocationCase,
|
|
10
|
+
RecordedStream,
|
|
11
|
+
ReplayedProcess,
|
|
12
|
+
assert_agent_obligations,
|
|
13
|
+
)
|
|
14
|
+
from shikiru.agents.testing.resume import RESUME_NONCE_PROMPT, RESUME_RECALL_PROMPT
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"RESUME_NONCE_PROMPT",
|
|
18
|
+
"RESUME_RECALL_PROMPT",
|
|
19
|
+
"AgentContractSuite",
|
|
20
|
+
"CompactionCase",
|
|
21
|
+
"CompactionScenario",
|
|
22
|
+
"InvocationCase",
|
|
23
|
+
"RecordedStream",
|
|
24
|
+
"ReplayedProcess",
|
|
25
|
+
"assert_agent_obligations",
|
|
26
|
+
]
|