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
shikiru/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Shikiru: an engine that runs coding-agent tasks in tmux panes."""
|
shikiru/__main__.py
ADDED
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
"""The headless agent plugin contract."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shlex
|
|
6
|
+
import sys
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from difflib import get_close_matches
|
|
9
|
+
from enum import StrEnum, auto
|
|
10
|
+
from typing import TYPE_CHECKING, Any, ClassVar, Final, Literal, Protocol, cast
|
|
11
|
+
|
|
12
|
+
from pydantic import ConfigDict
|
|
13
|
+
|
|
14
|
+
from shikiru.errors import ConfigError
|
|
15
|
+
from shikiru.process import ignore_started
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from collections.abc import AsyncIterator, Callable, Mapping, Sequence
|
|
19
|
+
from datetime import datetime, timedelta
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from shikiru.clock import Clock
|
|
23
|
+
from shikiru.process import SubprocessRunner
|
|
24
|
+
|
|
25
|
+
_RUNG_SEPARATOR = ":"
|
|
26
|
+
_VALID_RUNG_PART_COUNTS = (2, 3)
|
|
27
|
+
|
|
28
|
+
RUN_DIR_ENV_VAR = "SHIKIRU_RUN_DIR"
|
|
29
|
+
ROLE_ENV_VAR = "SHIKIRU_ROLE"
|
|
30
|
+
PROJECT_ENV_VAR = "SHIKIRU_PROJECT"
|
|
31
|
+
TASK_ENV_VAR = "SHIKIRU_TASK"
|
|
32
|
+
SLOT_ENV_VAR = "SHIKIRU_SLOT"
|
|
33
|
+
SHIKIRU_MODULE = "shikiru"
|
|
34
|
+
DOCTOR_TIMEOUT_SECONDS = 60.0
|
|
35
|
+
PERCENT = 100
|
|
36
|
+
MODEL_SUGGESTION_CUTOFF = 0.6
|
|
37
|
+
COST_BASIS_UNKNOWN = "unknown"
|
|
38
|
+
COST_BASIS_BILLED = "billed"
|
|
39
|
+
COST_BASIS_API_EQUIVALENT = "api_equivalent"
|
|
40
|
+
_SAFE_PATH_FLAG = "-P"
|
|
41
|
+
_MODULE_FLAG = "-m"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def module_argv(module: str, *arguments: str) -> tuple[str, ...]:
|
|
45
|
+
"""Return the argv running `module` with this Shikiru installation's interpreter."""
|
|
46
|
+
return (sys.executable, _SAFE_PATH_FLAG, _MODULE_FLAG, module, *arguments)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def module_command(module: str, *arguments: str) -> str:
|
|
50
|
+
"""Return a shell command running `module` with this installation's interpreter."""
|
|
51
|
+
return shlex.join(module_argv(module, *arguments))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def shikiru_argv(*arguments: str) -> tuple[str, ...]:
|
|
55
|
+
"""Return the argv running this Shikiru installation, independent of PATH."""
|
|
56
|
+
return module_argv(SHIKIRU_MODULE, *arguments)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def shikiru_command(*arguments: str) -> str:
|
|
60
|
+
"""Return a shell command running this Shikiru installation, independent of PATH."""
|
|
61
|
+
return module_command(SHIKIRU_MODULE, *arguments)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def role_dir(run_dir: Path, role: str) -> Path:
|
|
65
|
+
"""Return `role`'s directory under `run_dir`, where its per-role files live."""
|
|
66
|
+
return run_dir / role
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
CostBasis = Literal["billed", "api_equivalent", "unknown"]
|
|
70
|
+
BillingMode = Literal["api", "subscription", "unknown"]
|
|
71
|
+
BillingSource = Literal["provider_reported", "operator_config", "unknown"]
|
|
72
|
+
|
|
73
|
+
BILLING_MODE_API: Final = "api"
|
|
74
|
+
BILLING_MODE_SUBSCRIPTION: Final = "subscription"
|
|
75
|
+
BILLING_MODE_UNKNOWN: Final = "unknown"
|
|
76
|
+
BILLING_SOURCE_PROVIDER_REPORTED: Final = "provider_reported"
|
|
77
|
+
BILLING_SOURCE_OPERATOR_CONFIG: Final = "operator_config"
|
|
78
|
+
BILLING_SOURCE_UNKNOWN: Final = "unknown"
|
|
79
|
+
BILLING_MODES: Final = (BILLING_MODE_API, BILLING_MODE_SUBSCRIPTION, BILLING_MODE_UNKNOWN)
|
|
80
|
+
BILLING_SOURCES: Final = (
|
|
81
|
+
BILLING_SOURCE_PROVIDER_REPORTED,
|
|
82
|
+
BILLING_SOURCE_OPERATOR_CONFIG,
|
|
83
|
+
BILLING_SOURCE_UNKNOWN,
|
|
84
|
+
)
|
|
85
|
+
_MISSING_BILLING_DECLARATION = object()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class FailureKind(StrEnum):
|
|
89
|
+
"""Why a turn failed; `AUTH` includes provider access refusals needing operator action."""
|
|
90
|
+
|
|
91
|
+
QUOTA = auto()
|
|
92
|
+
UNAVAILABLE = auto()
|
|
93
|
+
AUTH = auto()
|
|
94
|
+
OTHER = auto()
|
|
95
|
+
UNKNOWN = auto()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass(frozen=True)
|
|
99
|
+
class Rung:
|
|
100
|
+
"""One `{agent, model, effort}` entry of a role's ladder."""
|
|
101
|
+
|
|
102
|
+
__pydantic_config__: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
|
103
|
+
|
|
104
|
+
agent: str
|
|
105
|
+
model: str
|
|
106
|
+
effort: str | None = None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(frozen=True)
|
|
110
|
+
class Usage:
|
|
111
|
+
"""A session's cumulative token and priced cost usage, as reported by its agent."""
|
|
112
|
+
|
|
113
|
+
input_tokens: int
|
|
114
|
+
output_tokens: int
|
|
115
|
+
cache_read_tokens: int
|
|
116
|
+
cache_write_tokens: int
|
|
117
|
+
cost_usd: float | None
|
|
118
|
+
cost_basis: CostBasis
|
|
119
|
+
context_used_pct: float | None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass(frozen=True)
|
|
123
|
+
class BillingDeclaration:
|
|
124
|
+
"""A plugin's normalized billing mode and the source that established it."""
|
|
125
|
+
|
|
126
|
+
mode: BillingMode
|
|
127
|
+
source: BillingSource
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
UNKNOWN_BILLING_DECLARATION = BillingDeclaration(
|
|
131
|
+
mode=BILLING_MODE_UNKNOWN,
|
|
132
|
+
source=BILLING_SOURCE_UNKNOWN,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
QUOTA_WINDOW_FIVE_HOUR = "5h"
|
|
137
|
+
QUOTA_WINDOW_SEVEN_DAY = "7d"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass(frozen=True)
|
|
141
|
+
class QuotaWindow:
|
|
142
|
+
"""One quota window an agent plugin reports, with `used_pct` in percent (0-100)."""
|
|
143
|
+
|
|
144
|
+
name: str
|
|
145
|
+
used_pct: float
|
|
146
|
+
resets_at: datetime | None
|
|
147
|
+
observed_at: datetime
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
QUOTA_EXHAUSTED_PCT = 100.0
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def is_exhausted(window: QuotaWindow) -> bool:
|
|
154
|
+
"""Return whether `window` reports no remaining quota."""
|
|
155
|
+
return window.used_pct >= QUOTA_EXHAUSTED_PCT
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def exhausted_reset(windows: Sequence[QuotaWindow], now: datetime) -> datetime | None:
|
|
159
|
+
"""Return when every exhausted, not yet reset window is available again, or `None`.
|
|
160
|
+
|
|
161
|
+
Windows that are not exhausted or whose reset is unknown or past say nothing about when
|
|
162
|
+
quota returns, so a reading without such evidence leaves the reset unknown.
|
|
163
|
+
"""
|
|
164
|
+
pending = [
|
|
165
|
+
window.resets_at
|
|
166
|
+
for window in windows
|
|
167
|
+
if is_exhausted(window) and window.resets_at is not None and window.resets_at > now
|
|
168
|
+
]
|
|
169
|
+
return max(pending) if pending else None
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@dataclass(frozen=True)
|
|
173
|
+
class QuotaReading:
|
|
174
|
+
"""The latest usable quota sample for the account and bucket that gate an agent's dispatch.
|
|
175
|
+
|
|
176
|
+
A plugin returns only readings it can attribute to its own provider, account and quota
|
|
177
|
+
bucket; samples of other buckets, null windows or older evidence are never included.
|
|
178
|
+
"""
|
|
179
|
+
|
|
180
|
+
windows: tuple[QuotaWindow, ...]
|
|
181
|
+
observed_at: datetime
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@dataclass(frozen=True)
|
|
185
|
+
class CheckResult:
|
|
186
|
+
"""One named check from `Agent.doctor()`, such as binary, version or login."""
|
|
187
|
+
|
|
188
|
+
name: str
|
|
189
|
+
ok: bool
|
|
190
|
+
detail: str
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@dataclass(frozen=True)
|
|
194
|
+
class ModelChoice:
|
|
195
|
+
"""One model and its authoritative explicit effort choices."""
|
|
196
|
+
|
|
197
|
+
model: str
|
|
198
|
+
efforts: tuple[str, ...] | None
|
|
199
|
+
offered: bool = True
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@dataclass(frozen=True)
|
|
203
|
+
class ModelCatalog:
|
|
204
|
+
"""Authoritative agent model choices, or the reason discovery is unavailable."""
|
|
205
|
+
|
|
206
|
+
models: tuple[ModelChoice, ...] = ()
|
|
207
|
+
error: str | None = None
|
|
208
|
+
complete: bool = False
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@dataclass(frozen=True)
|
|
212
|
+
class ModelChoiceAssessment:
|
|
213
|
+
"""One configured choice; `supported` is `None` when catalog evidence is incomplete."""
|
|
214
|
+
|
|
215
|
+
supported: bool | None
|
|
216
|
+
detail: str
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@dataclass(frozen=True)
|
|
220
|
+
class SessionStarted:
|
|
221
|
+
"""A new or resumed agent session started."""
|
|
222
|
+
|
|
223
|
+
session_id: str
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@dataclass(frozen=True)
|
|
227
|
+
class Message:
|
|
228
|
+
"""Text emitted by the agent while it works."""
|
|
229
|
+
|
|
230
|
+
text: str
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
@dataclass(frozen=True)
|
|
234
|
+
class ToolStarted:
|
|
235
|
+
"""A named agent tool started with a short description."""
|
|
236
|
+
|
|
237
|
+
name: str
|
|
238
|
+
detail: str
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
class ToolOutcome(StrEnum):
|
|
242
|
+
"""A tool result the agent can reliably classify as successful or failed."""
|
|
243
|
+
|
|
244
|
+
SUCCESS = auto()
|
|
245
|
+
FAILURE = auto()
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@dataclass(frozen=True)
|
|
249
|
+
class ToolFinished:
|
|
250
|
+
"""A named agent tool finished."""
|
|
251
|
+
|
|
252
|
+
name: str
|
|
253
|
+
outcome: ToolOutcome | None = None
|
|
254
|
+
exit_code: int | None = None
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@dataclass(frozen=True)
|
|
258
|
+
class UsageReported:
|
|
259
|
+
"""The agent's cumulative usage for this session."""
|
|
260
|
+
|
|
261
|
+
usage: Usage
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
@dataclass(frozen=True)
|
|
265
|
+
class QuotaReported:
|
|
266
|
+
"""The quota windows reported by the agent."""
|
|
267
|
+
|
|
268
|
+
windows: tuple[QuotaWindow, ...]
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
@dataclass(frozen=True)
|
|
272
|
+
class Compacted:
|
|
273
|
+
"""The agent compacted its context, with token counts when reported."""
|
|
274
|
+
|
|
275
|
+
pre_tokens: int | None = None
|
|
276
|
+
post_tokens: int | None = None
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
@dataclass(frozen=True)
|
|
280
|
+
class TurnEnded:
|
|
281
|
+
"""A turn finished; `last_message` is the agent's final message for it."""
|
|
282
|
+
|
|
283
|
+
last_message: str
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
@dataclass(frozen=True)
|
|
287
|
+
class TurnFailed:
|
|
288
|
+
"""A turn failed; `resets_at` is set only for a known quota reset."""
|
|
289
|
+
|
|
290
|
+
kind: FailureKind
|
|
291
|
+
message: str
|
|
292
|
+
resets_at: datetime | None
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
type AgentSignal = (
|
|
296
|
+
SessionStarted
|
|
297
|
+
| Message
|
|
298
|
+
| ToolStarted
|
|
299
|
+
| ToolFinished
|
|
300
|
+
| UsageReported
|
|
301
|
+
| QuotaReported
|
|
302
|
+
| Compacted
|
|
303
|
+
| TurnEnded
|
|
304
|
+
| TurnFailed
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
@dataclass(frozen=True)
|
|
309
|
+
class TurnSpec:
|
|
310
|
+
"""Everything an agent needs to run one headless turn.
|
|
311
|
+
|
|
312
|
+
`on_process` receives the id of every process the turn spawns, a context retry included,
|
|
313
|
+
before that process starts running (`SubprocessRunner.spawn(on_started=...)`).
|
|
314
|
+
"""
|
|
315
|
+
|
|
316
|
+
rung: Rung
|
|
317
|
+
role: str
|
|
318
|
+
prompt: str
|
|
319
|
+
cwd: Path
|
|
320
|
+
run_dir: Path
|
|
321
|
+
turn_dir: Path
|
|
322
|
+
add_dirs: tuple[Path, ...]
|
|
323
|
+
env: Mapping[str, str]
|
|
324
|
+
session_id: str | None
|
|
325
|
+
usage: Usage | None
|
|
326
|
+
on_process: Callable[[int], None] = ignore_started
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
class Turn(Protocol):
|
|
330
|
+
"""One headless agent turn, with one consumer for its normalized signal stream."""
|
|
331
|
+
|
|
332
|
+
@property
|
|
333
|
+
def pid(self) -> int:
|
|
334
|
+
"""Return the operating system id for the process running now."""
|
|
335
|
+
...
|
|
336
|
+
|
|
337
|
+
def signals(self) -> AsyncIterator[AgentSignal]:
|
|
338
|
+
"""Yield this turn's normalized signals to its single reader."""
|
|
339
|
+
...
|
|
340
|
+
|
|
341
|
+
async def stop(self) -> None:
|
|
342
|
+
"""Terminate the process while leaving the session resumable."""
|
|
343
|
+
...
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
class HeadlessAgent(Protocol):
|
|
347
|
+
"""One-shot, schema-constrained calls without a session."""
|
|
348
|
+
|
|
349
|
+
async def ask(
|
|
350
|
+
self,
|
|
351
|
+
prompt: str,
|
|
352
|
+
schema: Mapping[str, Any],
|
|
353
|
+
model: str,
|
|
354
|
+
effort: str | None,
|
|
355
|
+
cwd: Path,
|
|
356
|
+
timeout: timedelta,
|
|
357
|
+
) -> Mapping[str, Any]:
|
|
358
|
+
"""Run one schema-constrained prompt and return its structured output."""
|
|
359
|
+
...
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
class Agent(Protocol):
|
|
363
|
+
"""A headless agent CLI plugin."""
|
|
364
|
+
|
|
365
|
+
name: str
|
|
366
|
+
|
|
367
|
+
async def start_turn(self, spec: TurnSpec) -> Turn:
|
|
368
|
+
"""Start one new or resumed agent turn."""
|
|
369
|
+
...
|
|
370
|
+
|
|
371
|
+
def interactive_command(self, session_id: str, run_dir: Path, rung: Rung) -> Sequence[str]:
|
|
372
|
+
"""Return the command that resumes `session_id` on `rung` for an operator take-over.
|
|
373
|
+
|
|
374
|
+
`rung.agent` owns `session_id`; `run_dir` is the task's run directory, and the command
|
|
375
|
+
runs in the task root.
|
|
376
|
+
"""
|
|
377
|
+
...
|
|
378
|
+
|
|
379
|
+
async def doctor(self, models: Sequence[str]) -> list[CheckResult]:
|
|
380
|
+
"""Run this agent's binary, version and login checks, plus checks for `models`.
|
|
381
|
+
|
|
382
|
+
`models` are the models the configured ladders select for this agent; empty when
|
|
383
|
+
the agent is only being detected.
|
|
384
|
+
"""
|
|
385
|
+
...
|
|
386
|
+
|
|
387
|
+
def headless(self) -> HeadlessAgent | None:
|
|
388
|
+
"""Return this agent's one-shot, schema-constrained caller when available."""
|
|
389
|
+
...
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
class BillingDeclarationProvider(Protocol):
|
|
393
|
+
"""Optional agent capability that supplies an explicit billing declaration."""
|
|
394
|
+
|
|
395
|
+
@property
|
|
396
|
+
def billing_declaration(self) -> BillingDeclaration: ...
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
class QuotaProbe(Protocol):
|
|
400
|
+
"""Optional agent capability that reads fresh quota evidence without running a turn."""
|
|
401
|
+
|
|
402
|
+
def read_quota(self, since: datetime) -> QuotaReading | None:
|
|
403
|
+
"""Return the latest applicable reading observed after `since`, or `None` if none exists.
|
|
404
|
+
|
|
405
|
+
It may block: the engine calls it on a thread of its own, one at a time per agent, and
|
|
406
|
+
never interrupts it. An unreadable source raises `ShikiruError`.
|
|
407
|
+
"""
|
|
408
|
+
...
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
class ModelCatalogProvider(Protocol):
|
|
412
|
+
"""Optional agent capability for authoritative model and per-model effort choices."""
|
|
413
|
+
|
|
414
|
+
async def model_catalog(self) -> ModelCatalog: ...
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
class _ModelCatalogMethod(Protocol):
|
|
418
|
+
async def __call__(self) -> object: ...
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def billing_declaration_for(agent: Agent) -> BillingDeclaration:
|
|
422
|
+
"""Return an agent's valid optional billing declaration, or unknown for old plugins."""
|
|
423
|
+
declaration = getattr(agent, "billing_declaration", _MISSING_BILLING_DECLARATION)
|
|
424
|
+
if declaration is _MISSING_BILLING_DECLARATION:
|
|
425
|
+
return UNKNOWN_BILLING_DECLARATION
|
|
426
|
+
if not isinstance(declaration, BillingDeclaration):
|
|
427
|
+
message = f"agent {agent.name!r} returned an invalid billing declaration"
|
|
428
|
+
raise ConfigError(message)
|
|
429
|
+
if declaration.mode not in BILLING_MODES or declaration.source not in BILLING_SOURCES:
|
|
430
|
+
message = f"agent {agent.name!r} returned an unsupported billing declaration"
|
|
431
|
+
raise ConfigError(message)
|
|
432
|
+
if declaration.mode != BILLING_MODE_UNKNOWN and declaration.source == BILLING_SOURCE_UNKNOWN:
|
|
433
|
+
message = f"agent {agent.name!r} declared a billing mode without its source"
|
|
434
|
+
raise ConfigError(message)
|
|
435
|
+
return declaration
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def quota_probe_for(agent: Agent) -> QuotaProbe | None:
|
|
439
|
+
"""Return an agent's optional quota probe capability, or `None` when it has none."""
|
|
440
|
+
probe: object = getattr(agent, "read_quota", None)
|
|
441
|
+
return cast("QuotaProbe", agent) if callable(probe) else None
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
async def model_catalog_for(agent: Agent) -> ModelCatalog | None:
|
|
445
|
+
"""Return an optional agent catalog, rejecting malformed plugin capability results."""
|
|
446
|
+
provider: object = getattr(agent, "model_catalog", None)
|
|
447
|
+
if provider is None:
|
|
448
|
+
return None
|
|
449
|
+
if not callable(provider):
|
|
450
|
+
message = f"agent {agent.name!r} returned an invalid model catalog capability"
|
|
451
|
+
raise ConfigError(message)
|
|
452
|
+
catalog_value: object = await cast("_ModelCatalogMethod", provider)()
|
|
453
|
+
if not isinstance(catalog_value, ModelCatalog):
|
|
454
|
+
message = f"agent {agent.name!r} returned an invalid model catalog"
|
|
455
|
+
raise ConfigError(message)
|
|
456
|
+
catalog = catalog_value
|
|
457
|
+
if catalog.error is not None and (
|
|
458
|
+
not catalog.error.strip() or catalog.models or catalog.complete
|
|
459
|
+
):
|
|
460
|
+
message = f"agent {agent.name!r} returned an inconsistent model catalog"
|
|
461
|
+
raise ConfigError(message)
|
|
462
|
+
model_names: set[str] = set()
|
|
463
|
+
for choice in catalog.models:
|
|
464
|
+
if not choice.model:
|
|
465
|
+
message = f"agent {agent.name!r} returned an invalid model choice"
|
|
466
|
+
raise ConfigError(message)
|
|
467
|
+
if choice.model in model_names:
|
|
468
|
+
message = f"agent {agent.name!r} returned duplicate model choices"
|
|
469
|
+
raise ConfigError(message)
|
|
470
|
+
model_names.add(choice.model)
|
|
471
|
+
if choice.efforts is not None and (
|
|
472
|
+
any(not effort for effort in choice.efforts)
|
|
473
|
+
or len(set(choice.efforts)) != len(choice.efforts)
|
|
474
|
+
):
|
|
475
|
+
message = f"agent {agent.name!r} returned invalid effort choices"
|
|
476
|
+
raise ConfigError(message)
|
|
477
|
+
return catalog
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def assess_model_choice(
|
|
481
|
+
model: str, effort: str | None, catalog: ModelCatalog
|
|
482
|
+
) -> ModelChoiceAssessment:
|
|
483
|
+
"""Classify a configured choice using only its agent's catalog evidence."""
|
|
484
|
+
if catalog.error is not None:
|
|
485
|
+
supported = None
|
|
486
|
+
detail = f"model catalog unavailable: {catalog.error}"
|
|
487
|
+
else:
|
|
488
|
+
model_choice = next((choice for choice in catalog.models if choice.model == model), None)
|
|
489
|
+
if model_choice is None:
|
|
490
|
+
if not catalog.complete:
|
|
491
|
+
supported = None
|
|
492
|
+
detail = f"model {model!r} could not be verified because the catalog is incomplete"
|
|
493
|
+
else:
|
|
494
|
+
names = tuple(choice.model for choice in catalog.models)
|
|
495
|
+
suggestion = _choice_suggestion(model, names)
|
|
496
|
+
supported = False
|
|
497
|
+
if suggestion is not None:
|
|
498
|
+
detail = f"unsupported model {model!r}; use {suggestion!r}"
|
|
499
|
+
else:
|
|
500
|
+
detail = (
|
|
501
|
+
f"unsupported model {model!r}; available model identifiers: "
|
|
502
|
+
f"{', '.join(names) or 'none'}"
|
|
503
|
+
)
|
|
504
|
+
elif effort is None:
|
|
505
|
+
supported = True
|
|
506
|
+
detail = f"model {model_choice.model!r} is listed; native effort default is selected"
|
|
507
|
+
elif model_choice.efforts is None:
|
|
508
|
+
supported = None
|
|
509
|
+
detail = (
|
|
510
|
+
f"effort {effort!r} for model {model_choice.model!r} could not be verified because "
|
|
511
|
+
"the catalog does not report per-model effort choices; leave effort unset"
|
|
512
|
+
)
|
|
513
|
+
elif not model_choice.efforts:
|
|
514
|
+
supported = False
|
|
515
|
+
detail = (
|
|
516
|
+
f"model {model_choice.model!r} has no explicit effort choices; leave effort unset"
|
|
517
|
+
)
|
|
518
|
+
else:
|
|
519
|
+
issue = _unsupported_effort_issue(effort, model_choice.model, model_choice.efforts)
|
|
520
|
+
supported = issue is None
|
|
521
|
+
detail = issue or f"model {model_choice.model!r} supports effort {effort!r}"
|
|
522
|
+
return ModelChoiceAssessment(supported, detail)
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _unsupported_effort_issue(
|
|
526
|
+
effort: str, model: str, supported_efforts: Sequence[str]
|
|
527
|
+
) -> str | None:
|
|
528
|
+
if effort in supported_efforts:
|
|
529
|
+
return None
|
|
530
|
+
suggestion = _choice_suggestion(effort, supported_efforts)
|
|
531
|
+
detail = (
|
|
532
|
+
f"unsupported effort {effort!r} for model {model!r}; "
|
|
533
|
+
f"supported efforts: {', '.join(supported_efforts)}"
|
|
534
|
+
)
|
|
535
|
+
if suggestion is not None:
|
|
536
|
+
detail = f"{detail}; use {suggestion!r}"
|
|
537
|
+
return detail
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _choice_suggestion(value: str, choices: Sequence[str]) -> str | None:
|
|
541
|
+
folded = {choice.casefold(): choice for choice in choices}
|
|
542
|
+
exact_casefold = folded.get(value.casefold())
|
|
543
|
+
if exact_casefold is not None:
|
|
544
|
+
return exact_casefold
|
|
545
|
+
matches = get_close_matches(
|
|
546
|
+
value.casefold(),
|
|
547
|
+
[choice.casefold() for choice in choices],
|
|
548
|
+
n=1,
|
|
549
|
+
cutoff=MODEL_SUGGESTION_CUTOFF,
|
|
550
|
+
)
|
|
551
|
+
if not matches:
|
|
552
|
+
return None
|
|
553
|
+
return folded[matches[0]]
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
@dataclass(frozen=True)
|
|
557
|
+
class AgentContext:
|
|
558
|
+
"""What an agent factory needs to validate options and build its plugin."""
|
|
559
|
+
|
|
560
|
+
options: Mapping[str, Any]
|
|
561
|
+
runner: SubprocessRunner
|
|
562
|
+
clock: Clock
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
type AgentFactory = Callable[[AgentContext], Agent]
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def parse_rung(text: str) -> Rung:
|
|
569
|
+
"""Parse `agent:model` or `agent:model:effort`, raising `ConfigError` if malformed."""
|
|
570
|
+
parts = text.split(_RUNG_SEPARATOR)
|
|
571
|
+
if len(parts) not in _VALID_RUNG_PART_COUNTS or not all(parts):
|
|
572
|
+
message = f"{text!r} is not a valid rung; expected agent:model or agent:model:effort"
|
|
573
|
+
if len(parts) > max(_VALID_RUNG_PART_COUNTS):
|
|
574
|
+
message = f"{message}; a model or effort containing ':' must use the table form"
|
|
575
|
+
raise ConfigError(message)
|
|
576
|
+
agent, model, *effort = parts
|
|
577
|
+
return Rung(agent=agent, model=model, effort=effort[0] if effort else None)
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def short_rung_text(rung: Rung) -> str | None:
|
|
581
|
+
"""Return `rung` as `agent:model[:effort]` when parsing that text gives back `rung`."""
|
|
582
|
+
parts = [rung.agent, rung.model]
|
|
583
|
+
if rung.effort is not None:
|
|
584
|
+
parts.append(rung.effort)
|
|
585
|
+
text = _RUNG_SEPARATOR.join(parts)
|
|
586
|
+
try:
|
|
587
|
+
parsed = parse_rung(text)
|
|
588
|
+
except ConfigError:
|
|
589
|
+
return None
|
|
590
|
+
return text if parsed == rung else None
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Resolves agent names to plugins, by entry point or direct registration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib.metadata import entry_points
|
|
6
|
+
from typing import TYPE_CHECKING, cast
|
|
7
|
+
|
|
8
|
+
from shikiru.errors import ConfigError
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from importlib.metadata import EntryPoint
|
|
12
|
+
|
|
13
|
+
from shikiru.agents.contracts import Agent, AgentContext, AgentFactory
|
|
14
|
+
|
|
15
|
+
AGENT_ENTRY_POINT_GROUP = "shikiru.agents"
|
|
16
|
+
PLUGIN_REINSTALL_HINT = (
|
|
17
|
+
"a typo in the name gives this error too; if the name is right, reinstall from the checkout: "
|
|
18
|
+
"uv tool install . --reinstall (editable: uv tool install --editable . --reinstall)"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AgentPluginError(ConfigError):
|
|
23
|
+
"""A named agent plugin is missing, failed to load, or rejected its options."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _distribution_name(entry_point: EntryPoint) -> str:
|
|
27
|
+
return entry_point.dist.name if entry_point.dist is not None else "unknown"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def load_unique_entry_point(
|
|
31
|
+
group: str, name: str, error_type: type[ConfigError], kind: str
|
|
32
|
+
) -> object:
|
|
33
|
+
"""Load the sole `name` entry point of `group`, raising `error_type` under `kind`'s label.
|
|
34
|
+
|
|
35
|
+
Raises when no entry point named `name` exists, when more than one distribution
|
|
36
|
+
registers it (naming every distribution instead of silently picking the first), or
|
|
37
|
+
when loading it raises.
|
|
38
|
+
"""
|
|
39
|
+
matches = [entry_point for entry_point in entry_points(group=group) if entry_point.name == name]
|
|
40
|
+
if not matches:
|
|
41
|
+
message = f"no {kind} named {name!r} is registered or installed; {PLUGIN_REINSTALL_HINT}"
|
|
42
|
+
raise error_type(message)
|
|
43
|
+
if len(matches) > 1:
|
|
44
|
+
distributions = ", ".join(_distribution_name(match) for match in matches)
|
|
45
|
+
message = f"{kind} {name!r} is registered by more than one distribution: {distributions}"
|
|
46
|
+
raise error_type(message)
|
|
47
|
+
try:
|
|
48
|
+
return matches[0].load()
|
|
49
|
+
except Exception as error:
|
|
50
|
+
message = f"{kind} {name!r} failed to load: {error}"
|
|
51
|
+
raise error_type(message) from error
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class AgentRegistry:
|
|
55
|
+
"""Builds a headless `Agent` by name, from a registered factory or entry point."""
|
|
56
|
+
|
|
57
|
+
def __init__(self) -> None:
|
|
58
|
+
"""Start with no directly registered factories; entry points are resolved lazily."""
|
|
59
|
+
self._factories: dict[str, AgentFactory] = {}
|
|
60
|
+
|
|
61
|
+
def register(self, name: str, factory: AgentFactory) -> None:
|
|
62
|
+
"""Register `factory` under `name`, taking precedence over any entry point of that name."""
|
|
63
|
+
self._factories[name] = factory
|
|
64
|
+
|
|
65
|
+
def names(self) -> tuple[str, ...]:
|
|
66
|
+
"""Return every installed or directly registered agent name in stable order."""
|
|
67
|
+
installed = (
|
|
68
|
+
entry_point.name for entry_point in entry_points(group=AGENT_ENTRY_POINT_GROUP)
|
|
69
|
+
)
|
|
70
|
+
return tuple(sorted(set(installed) | self._factories.keys()))
|
|
71
|
+
|
|
72
|
+
def create(self, name: str, context: AgentContext) -> Agent:
|
|
73
|
+
"""Build the `name` agent from `context`, naming it in any `AgentPluginError` raised."""
|
|
74
|
+
factory = self._factories.get(name)
|
|
75
|
+
if factory is None:
|
|
76
|
+
factory = self._load_entry_point(name)
|
|
77
|
+
try:
|
|
78
|
+
return factory(context)
|
|
79
|
+
except Exception as error:
|
|
80
|
+
message = f"agent plugin {name!r} failed: {error}"
|
|
81
|
+
raise AgentPluginError(message) from error
|
|
82
|
+
|
|
83
|
+
def _load_entry_point(self, name: str) -> AgentFactory:
|
|
84
|
+
loaded = load_unique_entry_point(
|
|
85
|
+
AGENT_ENTRY_POINT_GROUP, name, AgentPluginError, "agent plugin"
|
|
86
|
+
)
|
|
87
|
+
return cast("AgentFactory", loaded)
|