agentshim 0.7.0__tar.gz → 0.8.0__tar.gz
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.
- {agentshim-0.7.0 → agentshim-0.8.0}/CHANGELOG.md +39 -0
- {agentshim-0.7.0 → agentshim-0.8.0}/PKG-INFO +1 -1
- {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/__init__.py +11 -1
- {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/agent.py +4 -0
- {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/core/__init__.py +15 -1
- {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/core/events.py +32 -0
- {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/core/profile.py +19 -0
- {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/core/schema.py +14 -9
- agentshim-0.8.0/agentshim/core/skills.py +75 -0
- {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/core/turn.py +5 -1
- {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/providers/__init__.py +7 -2
- {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/providers/claude/events.py +9 -0
- {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/providers/claude/parser.py +41 -0
- {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/providers/claude/provider.py +10 -1
- {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/providers/claude/scripted.py +19 -4
- {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/providers/codex/parser.py +3 -0
- {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/providers/codex/provider.py +4 -0
- {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/providers/codex/scripted.py +17 -1
- agentshim-0.8.0/agentshim/providers/codex/skills.py +27 -0
- {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/providers/copilot/scripted.py +12 -1
- {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/providers/gemini/scripted.py +12 -1
- {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/providers/opencode/scripted.py +12 -1
- {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/testing/__init__.py +12 -1
- {agentshim-0.7.0 → agentshim-0.8.0}/pyproject.toml +1 -1
- {agentshim-0.7.0 → agentshim-0.8.0}/.gitignore +0 -0
- {agentshim-0.7.0 → agentshim-0.8.0}/README.md +0 -0
- {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/core/_files.py +0 -0
- {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/core/env.py +0 -0
- {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/core/errors.py +0 -0
- {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/core/mcp.py +0 -0
- {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/core/pricing.py +0 -0
- {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/core/provider.py +0 -0
- {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/core/stream.py +0 -0
- {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/core/usage.py +0 -0
- {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/execution/__init__.py +0 -0
- {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/execution/executor.py +0 -0
- {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/execution/host.py +0 -0
- {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/execution/transform.py +0 -0
- {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/providers/claude/__init__.py +0 -0
- {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/providers/claude/hooks/__init__.py +0 -0
- {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/providers/claude/hooks/confine_reads.py +0 -0
- {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/providers/claude/sandbox.py +0 -0
- {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/providers/claude/user_hooks.py +0 -0
- {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/providers/codex/__init__.py +0 -0
- {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/providers/codex/_toml.py +0 -0
- {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/providers/codex/events.py +0 -0
- {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/providers/codex/rules.py +0 -0
- {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/providers/codex/sandbox.py +0 -0
- {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/providers/copilot/__init__.py +0 -0
- {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/providers/copilot/events.py +0 -0
- {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/providers/copilot/parser.py +0 -0
- {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/providers/copilot/provider.py +0 -0
- {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/providers/gemini/__init__.py +0 -0
- {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/providers/gemini/events.py +0 -0
- {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/providers/gemini/parser.py +0 -0
- {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/providers/gemini/provider.py +0 -0
- {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/providers/opencode/__init__.py +0 -0
- {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/providers/opencode/events.py +0 -0
- {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/providers/opencode/parser.py +0 -0
- {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/providers/opencode/provider.py +0 -0
- {agentshim-0.7.0 → agentshim-0.8.0}/agentshim/py.typed +0 -0
|
@@ -1,5 +1,44 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.0 (2026-10-03)
|
|
4
|
+
|
|
5
|
+
Skill observability. Additive: a caller that ignores the new events and the
|
|
6
|
+
new `TurnResult` field sees no change, but an exhaustive match over
|
|
7
|
+
`AgentEvent` gains two members.
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- `SkillsDiscovered(names)` and `SkillInvoked(name, source_path, tool_id)`
|
|
12
|
+
events. `SkillInvoked` sits directly after the `ToolCall` that loaded the
|
|
13
|
+
skill.
|
|
14
|
+
- `TurnResult.skills`: a `SkillSummary` (`discovered`, `invocations`,
|
|
15
|
+
`invoked`, `invocation_count`) folded from those events by `SkillTracker`,
|
|
16
|
+
which callers can also register as a handler to cover several turns.
|
|
17
|
+
`None` means unknown, never zero.
|
|
18
|
+
- `ProviderProfile.skill_discovery` and `skill_invocation`, each a
|
|
19
|
+
`SkillSignal` (`NONE`, `STRUCTURED`, `INFERRED`).
|
|
20
|
+
- Claude Code: discovery from the `system/init` `skills` list; a load is a
|
|
21
|
+
`Skill` tool call or a `Read` of a `SKILL.md`.
|
|
22
|
+
- Codex: a load is inferred from a shell command that names a `SKILL.md`
|
|
23
|
+
under a `skills/` directory. `codex exec --json` does not list offered
|
|
24
|
+
skills, so discovery is unknown.
|
|
25
|
+
- Gemini, opencode and Copilot report unknown for both.
|
|
26
|
+
- `scripted_turn(..., skills_offered=[...], skills_invoked=[...])` scripts the
|
|
27
|
+
offered list (Claude) and skill loads (Claude, Codex) in each provider's own
|
|
28
|
+
format; a provider whose stream cannot carry them raises `ValueError`.
|
|
29
|
+
- Recorded skill streams under `tests/fixtures/{claude,codex}/` and a live
|
|
30
|
+
e2e suite, `tests/e2e/test_skills_e2e.py`, with a positive and a negative
|
|
31
|
+
turn per provider.
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
|
|
35
|
+
- `normalize(schema, SchemaDialect.STRICT)` no longer closes an open map
|
|
36
|
+
(`additionalProperties` a schema or `true`), which silently turned
|
|
37
|
+
`dict[str, float]` into an object that can only be empty. The map is kept
|
|
38
|
+
and `dialect_problems` on the normalized schema reports it, so
|
|
39
|
+
`dialect_problems(normalize(s, d), d) == []` is the check for whether a
|
|
40
|
+
generated schema can go native.
|
|
41
|
+
|
|
3
42
|
## 0.7.0 (2026-09-27)
|
|
4
43
|
|
|
5
44
|
First-class cache accounting and a static pricing table. Additive except one
|
|
@@ -49,6 +49,11 @@ from .core import (
|
|
|
49
49
|
SchemaDialectError,
|
|
50
50
|
SessionResumeError,
|
|
51
51
|
SessionStarted,
|
|
52
|
+
SkillInvoked,
|
|
53
|
+
SkillsDiscovered,
|
|
54
|
+
SkillSignal,
|
|
55
|
+
SkillSummary,
|
|
56
|
+
SkillTracker,
|
|
52
57
|
Stderr,
|
|
53
58
|
StdioMcpServer,
|
|
54
59
|
StreamParser,
|
|
@@ -91,7 +96,7 @@ from .providers.copilot import CopilotProvider
|
|
|
91
96
|
from .providers.gemini import GeminiProvider
|
|
92
97
|
from .providers.opencode import OpencodeProvider
|
|
93
98
|
|
|
94
|
-
__version__ = "0.
|
|
99
|
+
__version__ = "0.8.0"
|
|
95
100
|
|
|
96
101
|
__all__ = [
|
|
97
102
|
"AgentEvent",
|
|
@@ -153,6 +158,11 @@ __all__ = [
|
|
|
153
158
|
"SchemaDialectError",
|
|
154
159
|
"SessionResumeError",
|
|
155
160
|
"SessionStarted",
|
|
161
|
+
"SkillInvoked",
|
|
162
|
+
"SkillSignal",
|
|
163
|
+
"SkillSummary",
|
|
164
|
+
"SkillTracker",
|
|
165
|
+
"SkillsDiscovered",
|
|
156
166
|
"Stderr",
|
|
157
167
|
"StdioMcpServer",
|
|
158
168
|
"StreamParser",
|
|
@@ -26,6 +26,7 @@ from agentshim.core.events import RunFinished, RunStarted, compose_event_handler
|
|
|
26
26
|
from agentshim.core.profile import McpMechanism, OutputSchemaStyle, SchemaDialect
|
|
27
27
|
from agentshim.core.provider import ArgvContext
|
|
28
28
|
from agentshim.core.schema import compact_json, dialect_problems, materialize
|
|
29
|
+
from agentshim.core.skills import SkillTracker
|
|
29
30
|
from agentshim.core.turn import TurnRequest, TurnResult, coerce_request
|
|
30
31
|
from agentshim.execution.executor import CommandRequest
|
|
31
32
|
from agentshim.execution.host import HostCommandExecutor
|
|
@@ -278,8 +279,10 @@ class AgentSession:
|
|
|
278
279
|
agent = self._agent
|
|
279
280
|
handler = agent.event_handler
|
|
280
281
|
resumed = self.session_id is not None
|
|
282
|
+
skills = SkillTracker(self.profile)
|
|
281
283
|
|
|
282
284
|
def emit(event: AgentEvent) -> None:
|
|
285
|
+
skills.on_event(event)
|
|
283
286
|
handler.on_event(event)
|
|
284
287
|
|
|
285
288
|
argv = list(command.argv)
|
|
@@ -305,6 +308,7 @@ class AgentSession:
|
|
|
305
308
|
cost_usd=parsed.cost_usd,
|
|
306
309
|
duration_ms=duration_ms,
|
|
307
310
|
exit_code=result.returncode,
|
|
311
|
+
skills=skills.summary(),
|
|
308
312
|
)
|
|
309
313
|
self.last_result = turn_result
|
|
310
314
|
return turn_result
|
|
@@ -34,6 +34,8 @@ from .events import (
|
|
|
34
34
|
RunFinished,
|
|
35
35
|
RunStarted,
|
|
36
36
|
SessionStarted,
|
|
37
|
+
SkillInvoked,
|
|
38
|
+
SkillsDiscovered,
|
|
37
39
|
Stderr,
|
|
38
40
|
ToolCall,
|
|
39
41
|
ToolResult,
|
|
@@ -51,9 +53,16 @@ from .mcp import (
|
|
|
51
53
|
install_config_file,
|
|
52
54
|
)
|
|
53
55
|
from .pricing import ModelPricing, PricingTable, cost_usd, default_pricing, price_for
|
|
54
|
-
from .profile import
|
|
56
|
+
from .profile import (
|
|
57
|
+
McpMechanism,
|
|
58
|
+
OutputSchemaStyle,
|
|
59
|
+
ProviderProfile,
|
|
60
|
+
SchemaDialect,
|
|
61
|
+
SkillSignal,
|
|
62
|
+
)
|
|
55
63
|
from .provider import ArgvContext, McpInstallation, ParsedTurn, Provider, StreamParser
|
|
56
64
|
from .schema import compact_json, dialect_problems, materialize, normalize
|
|
65
|
+
from .skills import SkillSummary, SkillTracker
|
|
57
66
|
from .stream import ToolTracker, parse_json_object
|
|
58
67
|
from .turn import OutputSchema, TurnRequest, TurnResult
|
|
59
68
|
from .usage import ProviderUsage, TokenUsage, TokenWeights, normalized_usage
|
|
@@ -100,6 +109,11 @@ __all__ = [
|
|
|
100
109
|
"SchemaDialectError",
|
|
101
110
|
"SessionResumeError",
|
|
102
111
|
"SessionStarted",
|
|
112
|
+
"SkillInvoked",
|
|
113
|
+
"SkillSignal",
|
|
114
|
+
"SkillSummary",
|
|
115
|
+
"SkillTracker",
|
|
116
|
+
"SkillsDiscovered",
|
|
103
117
|
"Stderr",
|
|
104
118
|
"StdioMcpServer",
|
|
105
119
|
"StreamParser",
|
|
@@ -74,6 +74,34 @@ class ToolResult:
|
|
|
74
74
|
duration_s: float | None
|
|
75
75
|
|
|
76
76
|
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class SkillsDiscovered:
|
|
79
|
+
"""The provider listed the skills it offers this session.
|
|
80
|
+
|
|
81
|
+
``names`` are the skill names as the provider reports them (a plugin
|
|
82
|
+
skill may carry a ``plugin:`` prefix). Only emitted by a provider whose
|
|
83
|
+
profile declares ``skill_discovery`` other than ``SkillSignal.NONE``, and
|
|
84
|
+
only when its stream actually carried the list.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
names: tuple[str, ...]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True)
|
|
91
|
+
class SkillInvoked:
|
|
92
|
+
"""The agent loaded a skill's instructions.
|
|
93
|
+
|
|
94
|
+
Emitted at the point in the stream where the load happened, directly
|
|
95
|
+
after the ``ToolCall`` that performed it; ``tool_id`` names that call.
|
|
96
|
+
``source_path`` is the skill file or directory when the stream reveals
|
|
97
|
+
it, and ``None`` otherwise.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
name: str
|
|
101
|
+
source_path: str | None = None
|
|
102
|
+
tool_id: str | None = None
|
|
103
|
+
|
|
104
|
+
|
|
77
105
|
@dataclass(frozen=True)
|
|
78
106
|
class UsageReport:
|
|
79
107
|
"""Provider accounting for the turn so far."""
|
|
@@ -119,6 +147,8 @@ AgentEvent = (
|
|
|
119
147
|
| Reasoning
|
|
120
148
|
| ToolCall
|
|
121
149
|
| ToolResult
|
|
150
|
+
| SkillsDiscovered
|
|
151
|
+
| SkillInvoked
|
|
122
152
|
| UsageReport
|
|
123
153
|
| Lifecycle
|
|
124
154
|
| Stderr
|
|
@@ -351,6 +381,8 @@ class ConsoleEventHandler:
|
|
|
351
381
|
"""
|
|
352
382
|
if isinstance(event, Stderr):
|
|
353
383
|
self._line(f"[stderr] {event.text.rstrip()}")
|
|
384
|
+
elif isinstance(event, SkillInvoked):
|
|
385
|
+
self._line(self._paint(f"[Skill] {event.name}", self._BLUE))
|
|
354
386
|
elif isinstance(event, ProviderError):
|
|
355
387
|
self._line(f"[error] {event.message}")
|
|
356
388
|
elif isinstance(event, Lifecycle) and self._show_lifecycle:
|
|
@@ -46,6 +46,21 @@ class SchemaDialect(Enum):
|
|
|
46
46
|
OPEN = "open"
|
|
47
47
|
|
|
48
48
|
|
|
49
|
+
class SkillSignal(Enum):
|
|
50
|
+
"""How much a provider's output stream says about skills.
|
|
51
|
+
|
|
52
|
+
``NONE``: nothing; a caller must treat skill use as unknown, never as
|
|
53
|
+
zero. ``STRUCTURED``: the CLI emits a dedicated, documented frame.
|
|
54
|
+
``INFERRED``: agentshim derives it from tool activity (for example a
|
|
55
|
+
shell read of a skill's ``SKILL.md``), so a load by other means can be
|
|
56
|
+
missed.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
NONE = "none"
|
|
60
|
+
STRUCTURED = "structured"
|
|
61
|
+
INFERRED = "inferred"
|
|
62
|
+
|
|
63
|
+
|
|
49
64
|
def _no_container_env() -> Mapping[str, str]:
|
|
50
65
|
"""Empty read-only default: a frozen spec must not carry a mutable one.
|
|
51
66
|
|
|
@@ -90,3 +105,7 @@ class ProviderProfile:
|
|
|
90
105
|
state_root_env: str | None = None
|
|
91
106
|
auth_files: tuple[str, ...] = ()
|
|
92
107
|
mcp_config_file: str | None = None
|
|
108
|
+
#: Whether the stream lists the skills offered (``SkillsDiscovered``).
|
|
109
|
+
skill_discovery: SkillSignal = SkillSignal.NONE
|
|
110
|
+
#: Whether the stream reveals skill loads (``SkillInvoked``).
|
|
111
|
+
skill_invocation: SkillSignal = SkillSignal.NONE
|
|
@@ -272,9 +272,11 @@ def normalize(schema: Mapping[str, Any], dialect: SchemaDialect) -> dict[str, An
|
|
|
272
272
|
requires every declared property, drops ``default``, strips the annotation
|
|
273
273
|
siblings of a ``$ref`` that the strict subset forbids, and removes the
|
|
274
274
|
document metadata (``$schema``, ``$id``, ``title``, ``description``,
|
|
275
|
-
``examples``) that ``dialect_problems`` reports under ``STRICT``.
|
|
276
|
-
|
|
277
|
-
|
|
275
|
+
``examples``) that ``dialect_problems`` reports under ``STRICT``. An open
|
|
276
|
+
map (``additionalProperties`` a schema or ``true``) is kept, since closing
|
|
277
|
+
it would change what the schema accepts; so ``dialect_problems`` on the
|
|
278
|
+
normalized schema reports exactly what the dialect cannot express.
|
|
279
|
+
Callers that want the schema passed through untouched skip this.
|
|
278
280
|
"""
|
|
279
281
|
return cast("dict[str, Any]", _normalized(copy.deepcopy(dict(schema)), dialect))
|
|
280
282
|
|
|
@@ -314,12 +316,15 @@ def _normalized(node: object, dialect: SchemaDialect) -> object:
|
|
|
314
316
|
|
|
315
317
|
|
|
316
318
|
def _close_object(mapping: dict[str, Any], dialect: SchemaDialect) -> None:
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
319
|
+
"""Close an object whose extra keys are merely unspecified.
|
|
320
|
+
|
|
321
|
+
An open map (``additionalProperties`` a schema or ``true``) is left open
|
|
322
|
+
in every dialect: closing it would silently turn ``dict[str, float]`` into
|
|
323
|
+
an object that can only be empty. Under ``STRICT`` it stays a problem for
|
|
324
|
+
``dialect_problems`` to report on the normalized schema.
|
|
325
|
+
"""
|
|
326
|
+
del dialect # an open map is left open in every dialect
|
|
327
|
+
if mapping.get("additionalProperties") in (None, False):
|
|
323
328
|
mapping["additionalProperties"] = False
|
|
324
329
|
|
|
325
330
|
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""What a turn did with skills, folded from its events.
|
|
2
|
+
|
|
3
|
+
``SkillTracker`` is an ordinary event handler, so the summary on
|
|
4
|
+
``TurnResult.skills`` and any caller that watches the stream itself read the
|
|
5
|
+
same events: there is one source of truth.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from .events import SkillInvoked, SkillsDiscovered
|
|
14
|
+
from .profile import SkillSignal
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from .events import AgentEvent
|
|
18
|
+
from .profile import ProviderProfile
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class SkillSummary:
|
|
23
|
+
"""Skills a turn (or several) offered and loaded.
|
|
24
|
+
|
|
25
|
+
``None`` means unknown: the provider's stream carries no such signal, or
|
|
26
|
+
did not carry it this time. It never means zero. An empty tuple means the
|
|
27
|
+
signal was available and nothing matched.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
discovered: tuple[str, ...] | None = None
|
|
31
|
+
invocations: tuple[SkillInvoked, ...] | None = None
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def invoked(self) -> tuple[str, ...] | None:
|
|
35
|
+
"""Distinct invoked skill names, in order of first use."""
|
|
36
|
+
if self.invocations is None:
|
|
37
|
+
return None
|
|
38
|
+
return tuple(dict.fromkeys(event.name for event in self.invocations))
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def invocation_count(self) -> int | None:
|
|
42
|
+
"""How many loads happened, or ``None`` when unknown."""
|
|
43
|
+
if self.invocations is None:
|
|
44
|
+
return None
|
|
45
|
+
return len(self.invocations)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class SkillTracker:
|
|
49
|
+
"""Event handler that folds skill events into a ``SkillSummary``.
|
|
50
|
+
|
|
51
|
+
Feed it one turn or many; the summary covers everything it has seen.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(self, profile: ProviderProfile) -> None:
|
|
55
|
+
"""Track skills for a provider with *profile*'s declared signals."""
|
|
56
|
+
self._invocation_known = profile.skill_invocation is not SkillSignal.NONE
|
|
57
|
+
self._discovered: list[str] | None = None
|
|
58
|
+
self._invocations: list[SkillInvoked] = []
|
|
59
|
+
|
|
60
|
+
def on_event(self, event: AgentEvent) -> None:
|
|
61
|
+
"""Record a skill event; ignore every other event."""
|
|
62
|
+
if isinstance(event, SkillsDiscovered):
|
|
63
|
+
known = self._discovered if self._discovered is not None else []
|
|
64
|
+
known.extend(name for name in event.names if name not in known)
|
|
65
|
+
self._discovered = known
|
|
66
|
+
elif isinstance(event, SkillInvoked):
|
|
67
|
+
self._invocations.append(event)
|
|
68
|
+
|
|
69
|
+
def summary(self) -> SkillSummary:
|
|
70
|
+
"""Return what has been seen so far."""
|
|
71
|
+
discovered = tuple(self._discovered) if self._discovered is not None else None
|
|
72
|
+
invocations: tuple[SkillInvoked, ...] | None = tuple(self._invocations)
|
|
73
|
+
if not self._invocation_known and not self._invocations:
|
|
74
|
+
invocations = None
|
|
75
|
+
return SkillSummary(discovered=discovered, invocations=invocations)
|
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from dataclasses import dataclass
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
6
|
from typing import TYPE_CHECKING, Any
|
|
7
7
|
|
|
8
|
+
from .skills import SkillSummary
|
|
9
|
+
|
|
8
10
|
if TYPE_CHECKING:
|
|
9
11
|
from collections.abc import Mapping, Sequence
|
|
10
12
|
from pathlib import Path
|
|
@@ -62,6 +64,8 @@ class TurnResult:
|
|
|
62
64
|
cost_usd: float | None
|
|
63
65
|
duration_ms: int
|
|
64
66
|
exit_code: int
|
|
67
|
+
#: Skills offered and loaded during the turn, derived from its events.
|
|
68
|
+
skills: SkillSummary = field(default_factory=SkillSummary)
|
|
65
69
|
|
|
66
70
|
|
|
67
71
|
def coerce_request(request: TurnRequest | str) -> TurnRequest:
|
|
@@ -36,7 +36,7 @@ if TYPE_CHECKING:
|
|
|
36
36
|
class ScriptedLines(Protocol):
|
|
37
37
|
"""Builds one turn's stdout in a provider's own stream format."""
|
|
38
38
|
|
|
39
|
-
def __call__(
|
|
39
|
+
def __call__( # noqa: PLR0913 # mirrors scripted_turn's independent knobs
|
|
40
40
|
self,
|
|
41
41
|
*,
|
|
42
42
|
text: str = "",
|
|
@@ -44,12 +44,17 @@ class ScriptedLines(Protocol):
|
|
|
44
44
|
usage: TokenUsage | None = None,
|
|
45
45
|
tool_calls: Sequence[tuple[str, Mapping[str, Any], str]] = (),
|
|
46
46
|
structured_output: object | None = None,
|
|
47
|
+
skills_offered: Sequence[str] | None = None,
|
|
48
|
+
skills_invoked: Sequence[str] = (),
|
|
47
49
|
) -> list[str]:
|
|
48
50
|
"""Return the stdout lines of one scripted turn.
|
|
49
51
|
|
|
50
52
|
``tool_calls`` entries are ``(tool, args, output)``;
|
|
51
53
|
``structured_output`` is any JSON-serializable value the turn should
|
|
52
|
-
report as its structured payload.
|
|
54
|
+
report as its structured payload. ``skills_offered`` is the skill
|
|
55
|
+
list the provider announces and ``skills_invoked`` the skills the turn
|
|
56
|
+
loads, each scripted the way the provider shows a load; a provider
|
|
57
|
+
whose stream cannot carry either raises ``ValueError``.
|
|
53
58
|
"""
|
|
54
59
|
...
|
|
55
60
|
|
|
@@ -20,6 +20,8 @@ class SystemInit:
|
|
|
20
20
|
|
|
21
21
|
session_id: str | None
|
|
22
22
|
subtype: str | None
|
|
23
|
+
#: The ``skills`` list of an ``init`` frame; ``None`` when absent.
|
|
24
|
+
skills: tuple[str, ...] | None = None
|
|
23
25
|
|
|
24
26
|
|
|
25
27
|
@dataclass(frozen=True)
|
|
@@ -93,6 +95,7 @@ def parse_frame(data: Mapping[str, Any]) -> ClaudeFrame | None:
|
|
|
93
95
|
return SystemInit(
|
|
94
96
|
session_id=_str_or_none(data.get("session_id")),
|
|
95
97
|
subtype=_str_or_none(data.get("subtype")),
|
|
98
|
+
skills=_str_tuple_or_none(data.get("skills")),
|
|
96
99
|
)
|
|
97
100
|
if kind == "assistant":
|
|
98
101
|
return _assistant(data)
|
|
@@ -208,6 +211,12 @@ def _str_or_none(value: object) -> str | None:
|
|
|
208
211
|
return value if isinstance(value, str) else None
|
|
209
212
|
|
|
210
213
|
|
|
214
|
+
def _str_tuple_or_none(value: object) -> tuple[str, ...] | None:
|
|
215
|
+
if not isinstance(value, list):
|
|
216
|
+
return None
|
|
217
|
+
return tuple(item for item in cast("list[object]", value) if isinstance(item, str))
|
|
218
|
+
|
|
219
|
+
|
|
211
220
|
def _int_or_none(value: object) -> int | None:
|
|
212
221
|
if isinstance(value, bool):
|
|
213
222
|
return None
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
|
+
import re
|
|
6
7
|
from typing import TYPE_CHECKING, Any, cast
|
|
7
8
|
|
|
8
9
|
from agentshim.core.events import (
|
|
@@ -11,6 +12,8 @@ from agentshim.core.events import (
|
|
|
11
12
|
RawOutput,
|
|
12
13
|
Reasoning,
|
|
13
14
|
SessionStarted,
|
|
15
|
+
SkillInvoked,
|
|
16
|
+
SkillsDiscovered,
|
|
14
17
|
Stderr,
|
|
15
18
|
ToolCall,
|
|
16
19
|
ToolResult,
|
|
@@ -37,6 +40,15 @@ if TYPE_CHECKING:
|
|
|
37
40
|
|
|
38
41
|
PROVIDER_NAME = "claude"
|
|
39
42
|
|
|
43
|
+
#: The built-in tool through which Claude Code loads a skill.
|
|
44
|
+
SKILL_TOOL = "Skill"
|
|
45
|
+
|
|
46
|
+
#: The built-in tool through which Claude Code reads a file.
|
|
47
|
+
READ_TOOL = "Read"
|
|
48
|
+
|
|
49
|
+
#: ``.../skills/<name>/SKILL.md``: a skill's instructions, read directly.
|
|
50
|
+
_SKILL_FILE = re.compile(r"/skills/(?:[^/]+/)*?(?P<name>[^/]+)/SKILL\.md$")
|
|
51
|
+
|
|
40
52
|
|
|
41
53
|
def fold_usage(usage: Mapping[str, Any] | None, turns: int = 0) -> TokenUsage:
|
|
42
54
|
"""Normalize Claude's usage mapping to the shared token counts.
|
|
@@ -76,6 +88,30 @@ def _int(value: object) -> int:
|
|
|
76
88
|
return 0
|
|
77
89
|
|
|
78
90
|
|
|
91
|
+
def skill_invocation(
|
|
92
|
+
tool_id: str | None, tool: str, args: Mapping[str, Any] | str | None
|
|
93
|
+
) -> SkillInvoked | None:
|
|
94
|
+
"""Recognize a tool call that loads a skill.
|
|
95
|
+
|
|
96
|
+
Claude Code loads a skill through its ``Skill`` tool. An agent can also
|
|
97
|
+
read a ``SKILL.md`` with ``Read``, which loads the same instructions.
|
|
98
|
+
"""
|
|
99
|
+
if not isinstance(args, dict):
|
|
100
|
+
return None
|
|
101
|
+
if tool == SKILL_TOOL:
|
|
102
|
+
name = args.get("skill")
|
|
103
|
+
if isinstance(name, str) and name:
|
|
104
|
+
return SkillInvoked(name=name, tool_id=tool_id)
|
|
105
|
+
return None
|
|
106
|
+
if tool == READ_TOOL:
|
|
107
|
+
path = args.get("file_path")
|
|
108
|
+
if isinstance(path, str):
|
|
109
|
+
match = _SKILL_FILE.search(path)
|
|
110
|
+
if match is not None:
|
|
111
|
+
return SkillInvoked(name=match["name"], source_path=path, tool_id=tool_id)
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
|
|
79
115
|
class ClaudeStreamParser:
|
|
80
116
|
"""Stateful parser for one Claude Code run."""
|
|
81
117
|
|
|
@@ -141,6 +177,8 @@ class ClaudeStreamParser:
|
|
|
141
177
|
if self._session_id is None and frame.session_id:
|
|
142
178
|
self._session_id = frame.session_id
|
|
143
179
|
self._emit(SessionStarted(frame.session_id))
|
|
180
|
+
if frame.skills is not None:
|
|
181
|
+
self._emit(SkillsDiscovered(frame.skills))
|
|
144
182
|
elif isinstance(frame, AssistantMessage):
|
|
145
183
|
self._assistant(frame)
|
|
146
184
|
elif isinstance(frame, ToolResultBlock):
|
|
@@ -170,6 +208,9 @@ class ClaudeStreamParser:
|
|
|
170
208
|
else:
|
|
171
209
|
self._tools.start(block.tool_id, block.tool)
|
|
172
210
|
self._emit(ToolCall(block.tool_id, block.tool, block.args))
|
|
211
|
+
skill = skill_invocation(block.tool_id, block.tool, block.args)
|
|
212
|
+
if skill is not None:
|
|
213
|
+
self._emit(skill)
|
|
173
214
|
|
|
174
215
|
def _tool_result(self, frame: ToolResultBlock) -> None:
|
|
175
216
|
name = self._tools.name(frame.tool_id)
|
|
@@ -7,7 +7,13 @@ from typing import TYPE_CHECKING, Any
|
|
|
7
7
|
|
|
8
8
|
from agentshim.core.errors import ProviderCapabilityError, SessionResumeError
|
|
9
9
|
from agentshim.core.mcp import HttpMcpServer, NoopInstallation, StdioMcpServer, install_config_file
|
|
10
|
-
from agentshim.core.profile import
|
|
10
|
+
from agentshim.core.profile import (
|
|
11
|
+
McpMechanism,
|
|
12
|
+
OutputSchemaStyle,
|
|
13
|
+
ProviderProfile,
|
|
14
|
+
SchemaDialect,
|
|
15
|
+
SkillSignal,
|
|
16
|
+
)
|
|
11
17
|
|
|
12
18
|
from .parser import ClaudeStreamParser
|
|
13
19
|
from .sandbox import SANDBOX_ENV, SandboxConfig, build_settings, resolve_sandbox
|
|
@@ -45,6 +51,9 @@ PROFILE = ProviderProfile(
|
|
|
45
51
|
"ANTHROPIC_CUSTOM_HEADERS",
|
|
46
52
|
),
|
|
47
53
|
skill_dirs=(".claude/skills",),
|
|
54
|
+
# ``system/init`` lists ``skills``; the ``Skill`` tool call names one.
|
|
55
|
+
skill_discovery=SkillSignal.STRUCTURED,
|
|
56
|
+
skill_invocation=SkillSignal.STRUCTURED,
|
|
48
57
|
container_install=(
|
|
49
58
|
"apt-get update && apt-get install -y --no-install-recommends curl ca-certificates",
|
|
50
59
|
"curl -fsSL https://claude.ai/install.sh | bash",
|
|
@@ -16,22 +16,37 @@ if TYPE_CHECKING:
|
|
|
16
16
|
from agentshim.core.usage import TokenUsage
|
|
17
17
|
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
# Mirrors scripted_turn: each option is an independent knob of the test double.
|
|
20
|
+
def scripted_lines( # noqa: PLR0913
|
|
20
21
|
*,
|
|
21
22
|
text: str = "",
|
|
22
23
|
session_id: str | None = None,
|
|
23
24
|
usage: TokenUsage | None = None,
|
|
24
25
|
tool_calls: Sequence[tuple[str, Mapping[str, Any], str]] = (),
|
|
25
26
|
structured_output: object | None = None,
|
|
27
|
+
skills_offered: Sequence[str] | None = None,
|
|
28
|
+
skills_invoked: Sequence[str] = (),
|
|
26
29
|
) -> list[str]:
|
|
27
30
|
"""Build the stdout of one Claude turn.
|
|
28
31
|
|
|
29
32
|
``tool_calls`` entries are ``(tool, args, output)`` and become a
|
|
30
|
-
``tool_use`` block plus the matching ``tool_result`` frame
|
|
33
|
+
``tool_use`` block plus the matching ``tool_result`` frame; a ``Skill``
|
|
34
|
+
call with ``{"skill": name}`` is a skill load, and each of
|
|
35
|
+
``skills_invoked`` becomes one ahead of ``tool_calls``. ``skills_offered``
|
|
36
|
+
goes in the ``init`` frame's ``skills`` list.
|
|
31
37
|
"""
|
|
38
|
+
tool_calls = [
|
|
39
|
+
*(("Skill", {"skill": name}, f"Launching skill: {name}") for name in skills_invoked),
|
|
40
|
+
*tool_calls,
|
|
41
|
+
]
|
|
32
42
|
lines: list[str] = []
|
|
33
|
-
if session_id is not None:
|
|
34
|
-
|
|
43
|
+
if session_id is not None or skills_offered is not None:
|
|
44
|
+
init: dict[str, Any] = {"type": "system", "subtype": "init"}
|
|
45
|
+
if session_id is not None:
|
|
46
|
+
init["session_id"] = session_id
|
|
47
|
+
if skills_offered is not None:
|
|
48
|
+
init["skills"] = list(skills_offered)
|
|
49
|
+
lines.append(_line(init))
|
|
35
50
|
|
|
36
51
|
blocks: list[dict[str, Any]] = []
|
|
37
52
|
if text:
|
|
@@ -34,6 +34,7 @@ from .events import (
|
|
|
34
34
|
parse_frame,
|
|
35
35
|
summarize_item,
|
|
36
36
|
)
|
|
37
|
+
from .skills import skill_reads
|
|
37
38
|
|
|
38
39
|
if TYPE_CHECKING:
|
|
39
40
|
from collections.abc import Callable
|
|
@@ -170,6 +171,8 @@ class CodexStreamParser:
|
|
|
170
171
|
if isinstance(item, CommandItem):
|
|
171
172
|
self._tools.start(item.item_id, COMMAND_TOOL)
|
|
172
173
|
self._emit(ToolCall(item.item_id, COMMAND_TOOL, {"command": item.command}))
|
|
174
|
+
for skill in skill_reads(item.command, item.item_id):
|
|
175
|
+
self._emit(skill)
|
|
173
176
|
elif isinstance(item, GenericItem):
|
|
174
177
|
self._tools.start(item.item_id, item.kind)
|
|
175
178
|
self._emit(ToolCall(item.item_id, item.kind, dict(item.fields)))
|
|
@@ -13,6 +13,7 @@ from agentshim.core.profile import (
|
|
|
13
13
|
OutputSchemaStyle,
|
|
14
14
|
ProviderProfile,
|
|
15
15
|
SchemaDialect,
|
|
16
|
+
SkillSignal,
|
|
16
17
|
)
|
|
17
18
|
|
|
18
19
|
from ._toml import toml_array, toml_str, unescape_toml
|
|
@@ -75,6 +76,9 @@ PROFILE = ProviderProfile(
|
|
|
75
76
|
),
|
|
76
77
|
auth_env_vars=("OPENAI_API_KEY", "OPENAI_BASE_URL"),
|
|
77
78
|
skill_dirs=(".agents/skills",),
|
|
79
|
+
# ``exec --json`` lists no skills; a load is inferred from a shell read
|
|
80
|
+
# of a ``SKILL.md`` (``skills.py``).
|
|
81
|
+
skill_invocation=SkillSignal.INFERRED,
|
|
78
82
|
container_install=(_NODE_INSTALL, _CODEX_INSTALL),
|
|
79
83
|
# Documented Codex CLI variable that relocates ~/.codex (``codex --help``:
|
|
80
84
|
# "Layer $CODEX_HOME/<name>.config.toml on top of the base user config";
|
|
@@ -16,13 +16,16 @@ if TYPE_CHECKING:
|
|
|
16
16
|
from agentshim.core.usage import TokenUsage
|
|
17
17
|
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
# Mirrors scripted_turn: each option is an independent knob of the test double.
|
|
20
|
+
def scripted_lines( # noqa: PLR0913
|
|
20
21
|
*,
|
|
21
22
|
text: str = "",
|
|
22
23
|
session_id: str | None = None,
|
|
23
24
|
usage: TokenUsage | None = None,
|
|
24
25
|
tool_calls: Sequence[tuple[str, Mapping[str, Any], str]] = (),
|
|
25
26
|
structured_output: object | None = None,
|
|
27
|
+
skills_offered: Sequence[str] | None = None,
|
|
28
|
+
skills_invoked: Sequence[str] = (),
|
|
26
29
|
) -> list[str]:
|
|
27
30
|
"""Build the stdout of one Codex turn.
|
|
28
31
|
|
|
@@ -38,10 +41,23 @@ def scripted_lines(
|
|
|
38
41
|
usage: Token counts reported by ``turn.completed``.
|
|
39
42
|
tool_calls: Commands to report as started-then-completed items.
|
|
40
43
|
structured_output: Payload to serialize as the final message.
|
|
44
|
+
skills_offered: Must be None; the stream lists no offered skills.
|
|
45
|
+
skills_invoked: Skills to load first, each as a shell read of its
|
|
46
|
+
``SKILL.md``.
|
|
41
47
|
|
|
42
48
|
Returns:
|
|
43
49
|
One newline-terminated JSON line per Codex event.
|
|
44
50
|
"""
|
|
51
|
+
tool_calls = [
|
|
52
|
+
*(
|
|
53
|
+
("execute", {"command": f"cat .agents/skills/{name}/SKILL.md"}, "")
|
|
54
|
+
for name in skills_invoked
|
|
55
|
+
),
|
|
56
|
+
*tool_calls,
|
|
57
|
+
]
|
|
58
|
+
if skills_offered is not None:
|
|
59
|
+
msg = "Codex does not list offered skills in its stream"
|
|
60
|
+
raise ValueError(msg)
|
|
45
61
|
lines: list[str] = []
|
|
46
62
|
if session_id is not None:
|
|
47
63
|
lines.append(_line({"type": "thread.started", "thread_id": session_id}))
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Recognize a Codex skill load in a shell command.
|
|
2
|
+
|
|
3
|
+
Codex has no skill event in ``codex exec --json``: it injects a list of
|
|
4
|
+
skills into the prompt and the model loads one by reading its ``SKILL.md``
|
|
5
|
+
with a shell command (``sed -n '1,240p' .../.agents/skills/<name>/SKILL.md``).
|
|
6
|
+
That read is the only trace of the load in the stream, so it is inferred
|
|
7
|
+
here and nowhere else.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
|
|
14
|
+
from agentshim.core.events import SkillInvoked
|
|
15
|
+
|
|
16
|
+
#: A path ending in ``skills/[<group>/...]<name>/SKILL.md``, as one shell word.
|
|
17
|
+
_SKILL_FILE = re.compile(
|
|
18
|
+
r"(?<![^\s'\"=])(?P<path>(?:[^\s'\"]*/)?skills/(?:[^\s'\"/]+/)*?(?P<name>[^\s'\"/]+)/SKILL\.md)(?![^\s'\";|&)])"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def skill_reads(command: str, tool_id: str | None) -> list[SkillInvoked]:
|
|
23
|
+
"""Return one ``SkillInvoked`` per ``SKILL.md`` the command names, in order."""
|
|
24
|
+
return [
|
|
25
|
+
SkillInvoked(name=match["name"], source_path=match["path"], tool_id=tool_id)
|
|
26
|
+
for match in _SKILL_FILE.finditer(command)
|
|
27
|
+
]
|
|
@@ -20,13 +20,16 @@ _NO_STRUCTURED_OUTPUT = (
|
|
|
20
20
|
)
|
|
21
21
|
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
# Mirrors scripted_turn: each option is an independent knob of the test double.
|
|
24
|
+
def scripted_lines( # noqa: PLR0913
|
|
24
25
|
*,
|
|
25
26
|
text: str = "",
|
|
26
27
|
session_id: str | None = None,
|
|
27
28
|
usage: TokenUsage | None = None,
|
|
28
29
|
tool_calls: Sequence[tuple[str, Mapping[str, Any], str]] = (),
|
|
29
30
|
structured_output: object | None = None,
|
|
31
|
+
skills_offered: Sequence[str] | None = None,
|
|
32
|
+
skills_invoked: Sequence[str] = (),
|
|
30
33
|
) -> list[str]:
|
|
31
34
|
"""Build the stdout of one Copilot turn.
|
|
32
35
|
|
|
@@ -40,6 +43,8 @@ def scripted_lines(
|
|
|
40
43
|
usage: Token counts to report in the ``assistant.usage`` frame.
|
|
41
44
|
tool_calls: Tool calls to script.
|
|
42
45
|
structured_output: Must be None; Copilot has no native output schema.
|
|
46
|
+
skills_offered: Must be None; the stream lists no offered skills.
|
|
47
|
+
skills_invoked: Must be empty; the stream reports no skill loads.
|
|
43
48
|
|
|
44
49
|
Returns:
|
|
45
50
|
The stdout lines, each a JSON object with a trailing newline.
|
|
@@ -47,6 +52,12 @@ def scripted_lines(
|
|
|
47
52
|
Raises:
|
|
48
53
|
ValueError: If ``structured_output`` is given.
|
|
49
54
|
"""
|
|
55
|
+
if skills_invoked:
|
|
56
|
+
msg = "Copilot reports no skill loads in its stream"
|
|
57
|
+
raise ValueError(msg)
|
|
58
|
+
if skills_offered is not None:
|
|
59
|
+
msg = "Copilot does not list offered skills in its stream"
|
|
60
|
+
raise ValueError(msg)
|
|
50
61
|
if structured_output is not None:
|
|
51
62
|
raise ValueError(_NO_STRUCTURED_OUTPUT)
|
|
52
63
|
|
|
@@ -25,13 +25,16 @@ _NO_STRUCTURED_OUTPUT = (
|
|
|
25
25
|
)
|
|
26
26
|
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
# Mirrors scripted_turn: each option is an independent knob of the test double.
|
|
29
|
+
def scripted_lines( # noqa: PLR0913
|
|
29
30
|
*,
|
|
30
31
|
text: str = "",
|
|
31
32
|
session_id: str | None = None,
|
|
32
33
|
usage: TokenUsage | None = None,
|
|
33
34
|
tool_calls: Sequence[tuple[str, Mapping[str, Any], str]] = (),
|
|
34
35
|
structured_output: object | None = None,
|
|
36
|
+
skills_offered: Sequence[str] | None = None,
|
|
37
|
+
skills_invoked: Sequence[str] = (),
|
|
35
38
|
) -> list[str]:
|
|
36
39
|
"""Build the stdout of one Gemini turn.
|
|
37
40
|
|
|
@@ -45,6 +48,8 @@ def scripted_lines(
|
|
|
45
48
|
usage: Token counts to report in the terminal ``result`` frame.
|
|
46
49
|
tool_calls: Tool calls to script.
|
|
47
50
|
structured_output: Must be None; Gemini has no native output schema.
|
|
51
|
+
skills_offered: Must be None; the stream lists no offered skills.
|
|
52
|
+
skills_invoked: Must be empty; the stream reports no skill loads.
|
|
48
53
|
|
|
49
54
|
Returns:
|
|
50
55
|
The stdout lines, each a JSON object with a trailing newline.
|
|
@@ -52,6 +57,12 @@ def scripted_lines(
|
|
|
52
57
|
Raises:
|
|
53
58
|
ValueError: If ``structured_output`` is given.
|
|
54
59
|
"""
|
|
60
|
+
if skills_invoked:
|
|
61
|
+
msg = "Gemini reports no skill loads in its stream"
|
|
62
|
+
raise ValueError(msg)
|
|
63
|
+
if skills_offered is not None:
|
|
64
|
+
msg = "Gemini does not list offered skills in its stream"
|
|
65
|
+
raise ValueError(msg)
|
|
55
66
|
if structured_output is not None:
|
|
56
67
|
raise ValueError(_NO_STRUCTURED_OUTPUT)
|
|
57
68
|
|
|
@@ -25,13 +25,16 @@ _NO_STRUCTURED_OUTPUT = (
|
|
|
25
25
|
)
|
|
26
26
|
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
# Mirrors scripted_turn: each option is an independent knob of the test double.
|
|
29
|
+
def scripted_lines( # noqa: PLR0913
|
|
29
30
|
*,
|
|
30
31
|
text: str = "",
|
|
31
32
|
session_id: str | None = None,
|
|
32
33
|
usage: TokenUsage | None = None,
|
|
33
34
|
tool_calls: Sequence[tuple[str, Mapping[str, Any], str]] = (),
|
|
34
35
|
structured_output: object | None = None,
|
|
36
|
+
skills_offered: Sequence[str] | None = None,
|
|
37
|
+
skills_invoked: Sequence[str] = (),
|
|
35
38
|
) -> list[str]:
|
|
36
39
|
"""Build the stdout of one opencode turn.
|
|
37
40
|
|
|
@@ -45,6 +48,8 @@ def scripted_lines(
|
|
|
45
48
|
usage: Token counts to report in the closing ``step_finish`` frame.
|
|
46
49
|
tool_calls: Tool calls to script.
|
|
47
50
|
structured_output: Must be None; opencode has no native output schema.
|
|
51
|
+
skills_offered: Must be None; the stream lists no offered skills.
|
|
52
|
+
skills_invoked: Must be empty; the stream reports no skill loads.
|
|
48
53
|
|
|
49
54
|
Returns:
|
|
50
55
|
The stdout lines, each a JSON object with a trailing newline.
|
|
@@ -52,6 +57,12 @@ def scripted_lines(
|
|
|
52
57
|
Raises:
|
|
53
58
|
ValueError: If ``structured_output`` is given.
|
|
54
59
|
"""
|
|
60
|
+
if skills_invoked:
|
|
61
|
+
msg = "opencode reports no skill loads in its stream"
|
|
62
|
+
raise ValueError(msg)
|
|
63
|
+
if skills_offered is not None:
|
|
64
|
+
msg = "opencode does not list offered skills in its stream"
|
|
65
|
+
raise ValueError(msg)
|
|
55
66
|
if structured_output is not None:
|
|
56
67
|
raise ValueError(_NO_STRUCTURED_OUTPUT)
|
|
57
68
|
|
|
@@ -160,14 +160,25 @@ def scripted_turn( # noqa: PLR0913
|
|
|
160
160
|
tool_calls: Sequence[tuple[str, Mapping[str, Any], str]] = (),
|
|
161
161
|
structured_output: object | None = None,
|
|
162
162
|
returncode: int = 0,
|
|
163
|
+
skills_offered: Sequence[str] | None = None,
|
|
164
|
+
skills_invoked: Sequence[str] = (),
|
|
163
165
|
) -> FakeRun:
|
|
164
|
-
"""Build a ``FakeRun`` whose stdout is *provider*'s real stream format.
|
|
166
|
+
"""Build a ``FakeRun`` whose stdout is *provider*'s real stream format.
|
|
167
|
+
|
|
168
|
+
``skills_invoked`` scripts one load per name, before ``tool_calls``, the
|
|
169
|
+
way *provider* shows a load, so the turn reports a ``SkillInvoked`` each.
|
|
170
|
+
``skills_offered`` scripts the offered list. Either raises ``ValueError``
|
|
171
|
+
on a provider whose stream cannot carry it (the matching
|
|
172
|
+
``profile.skill_*`` signal is ``NONE``).
|
|
173
|
+
"""
|
|
165
174
|
lines = get_scripted_lines(provider)(
|
|
166
175
|
text=text,
|
|
167
176
|
session_id=session_id,
|
|
168
177
|
usage=usage,
|
|
169
178
|
tool_calls=tool_calls,
|
|
170
179
|
structured_output=structured_output,
|
|
180
|
+
skills_offered=skills_offered,
|
|
181
|
+
skills_invoked=skills_invoked,
|
|
171
182
|
)
|
|
172
183
|
return FakeRun(stdout=lines, returncode=returncode)
|
|
173
184
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|