agentshim 0.7.0__tar.gz → 0.9.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.9.0}/CHANGELOG.md +60 -0
- {agentshim-0.7.0 → agentshim-0.9.0}/PKG-INFO +1 -1
- {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/__init__.py +13 -1
- {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/agent.py +26 -5
- {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/core/__init__.py +17 -1
- {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/core/events.py +32 -0
- {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/core/profile.py +37 -0
- {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/core/provider.py +4 -0
- {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/core/schema.py +14 -9
- agentshim-0.9.0/agentshim/core/skills.py +75 -0
- {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/core/turn.py +5 -1
- {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/providers/__init__.py +7 -2
- {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/providers/claude/events.py +9 -0
- {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/providers/claude/parser.py +41 -0
- {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/providers/claude/provider.py +25 -1
- {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/providers/claude/scripted.py +19 -4
- {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/providers/codex/parser.py +3 -0
- {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/providers/codex/provider.py +9 -0
- {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/providers/codex/scripted.py +17 -1
- agentshim-0.9.0/agentshim/providers/codex/skills.py +86 -0
- {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/providers/copilot/scripted.py +12 -1
- {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/providers/gemini/scripted.py +12 -1
- {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/providers/opencode/scripted.py +12 -1
- {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/testing/__init__.py +12 -1
- {agentshim-0.7.0 → agentshim-0.9.0}/pyproject.toml +1 -1
- {agentshim-0.7.0 → agentshim-0.9.0}/.gitignore +0 -0
- {agentshim-0.7.0 → agentshim-0.9.0}/README.md +0 -0
- {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/core/_files.py +0 -0
- {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/core/env.py +0 -0
- {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/core/errors.py +0 -0
- {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/core/mcp.py +0 -0
- {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/core/pricing.py +0 -0
- {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/core/stream.py +0 -0
- {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/core/usage.py +0 -0
- {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/execution/__init__.py +0 -0
- {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/execution/executor.py +0 -0
- {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/execution/host.py +0 -0
- {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/execution/transform.py +0 -0
- {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/providers/claude/__init__.py +0 -0
- {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/providers/claude/hooks/__init__.py +0 -0
- {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/providers/claude/hooks/confine_reads.py +0 -0
- {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/providers/claude/sandbox.py +0 -0
- {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/providers/claude/user_hooks.py +0 -0
- {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/providers/codex/__init__.py +0 -0
- {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/providers/codex/_toml.py +0 -0
- {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/providers/codex/events.py +0 -0
- {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/providers/codex/rules.py +0 -0
- {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/providers/codex/sandbox.py +0 -0
- {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/providers/copilot/__init__.py +0 -0
- {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/providers/copilot/events.py +0 -0
- {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/providers/copilot/parser.py +0 -0
- {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/providers/copilot/provider.py +0 -0
- {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/providers/gemini/__init__.py +0 -0
- {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/providers/gemini/events.py +0 -0
- {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/providers/gemini/parser.py +0 -0
- {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/providers/gemini/provider.py +0 -0
- {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/providers/opencode/__init__.py +0 -0
- {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/providers/opencode/events.py +0 -0
- {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/providers/opencode/parser.py +0 -0
- {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/providers/opencode/provider.py +0 -0
- {agentshim-0.7.0 → agentshim-0.9.0}/agentshim/py.typed +0 -0
|
@@ -1,5 +1,65 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.0 (2026-10-03)
|
|
4
|
+
|
|
5
|
+
Skill isolation. Additive: the default scope keeps today's behaviour.
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- `SkillScope` (`ALL`, `PROJECT`) and `start_session(skill_scope=...)` /
|
|
10
|
+
`run(skill_scope=...)`. `PROJECT` offers only the workspace's skills and
|
|
11
|
+
the CLI's built-in ones, not the user's personal skills or plugins.
|
|
12
|
+
- `ProviderProfile.skill_scopes` declares the scopes a provider enforces;
|
|
13
|
+
asking for another raises `ProviderCapabilityError` from `start_session`.
|
|
14
|
+
- `ArgvContext.skill_scope` carries the session's scope to `build_argv`.
|
|
15
|
+
- Claude Code: `PROJECT` passes `--setting-sources project,local`, which also
|
|
16
|
+
skips user settings and `~/.claude/CLAUDE.md`; credentials still work.
|
|
17
|
+
- Codex: `PROJECT` sets `features.plugins=false` and disables every user
|
|
18
|
+
`SKILL.md` (`$CODEX_HOME/skills` except `.system`, `~/.agents/skills`)
|
|
19
|
+
through `skills.config`.
|
|
20
|
+
- Gemini, opencode and Copilot support `ALL` only.
|
|
21
|
+
- Live e2e: `SkillScope.PROJECT` hides a skill seeded in a relocated user
|
|
22
|
+
home while the workspace skill stays offered (`tests/e2e/test_skills_e2e.py`).
|
|
23
|
+
|
|
24
|
+
## 0.8.0 (2026-10-03)
|
|
25
|
+
|
|
26
|
+
Skill observability. Additive: a caller that ignores the new events and the
|
|
27
|
+
new `TurnResult` field sees no change, but an exhaustive match over
|
|
28
|
+
`AgentEvent` gains two members.
|
|
29
|
+
|
|
30
|
+
### Added
|
|
31
|
+
|
|
32
|
+
- `SkillsDiscovered(names)` and `SkillInvoked(name, source_path, tool_id)`
|
|
33
|
+
events. `SkillInvoked` sits directly after the `ToolCall` that loaded the
|
|
34
|
+
skill.
|
|
35
|
+
- `TurnResult.skills`: a `SkillSummary` (`discovered`, `invocations`,
|
|
36
|
+
`invoked`, `invocation_count`) folded from those events by `SkillTracker`,
|
|
37
|
+
which callers can also register as a handler to cover several turns.
|
|
38
|
+
`None` means unknown, never zero.
|
|
39
|
+
- `ProviderProfile.skill_discovery` and `skill_invocation`, each a
|
|
40
|
+
`SkillSignal` (`NONE`, `STRUCTURED`, `INFERRED`).
|
|
41
|
+
- Claude Code: discovery from the `system/init` `skills` list; a load is a
|
|
42
|
+
`Skill` tool call or a `Read` of a `SKILL.md`.
|
|
43
|
+
- Codex: a load is inferred from a shell command that names a `SKILL.md`
|
|
44
|
+
under a `skills/` directory. `codex exec --json` does not list offered
|
|
45
|
+
skills, so discovery is unknown.
|
|
46
|
+
- Gemini, opencode and Copilot report unknown for both.
|
|
47
|
+
- `scripted_turn(..., skills_offered=[...], skills_invoked=[...])` scripts the
|
|
48
|
+
offered list (Claude) and skill loads (Claude, Codex) in each provider's own
|
|
49
|
+
format; a provider whose stream cannot carry them raises `ValueError`.
|
|
50
|
+
- Recorded skill streams under `tests/fixtures/{claude,codex}/` and a live
|
|
51
|
+
e2e suite, `tests/e2e/test_skills_e2e.py`, with a positive and a negative
|
|
52
|
+
turn per provider.
|
|
53
|
+
|
|
54
|
+
### Fixed
|
|
55
|
+
|
|
56
|
+
- `normalize(schema, SchemaDialect.STRICT)` no longer closes an open map
|
|
57
|
+
(`additionalProperties` a schema or `true`), which silently turned
|
|
58
|
+
`dict[str, float]` into an object that can only be empty. The map is kept
|
|
59
|
+
and `dialect_problems` on the normalized schema reports it, so
|
|
60
|
+
`dialect_problems(normalize(s, d), d) == []` is the check for whether a
|
|
61
|
+
generated schema can go native.
|
|
62
|
+
|
|
3
63
|
## 0.7.0 (2026-09-27)
|
|
4
64
|
|
|
5
65
|
First-class cache accounting and a static pricing table. Additive except one
|
|
@@ -49,6 +49,12 @@ from .core import (
|
|
|
49
49
|
SchemaDialectError,
|
|
50
50
|
SessionResumeError,
|
|
51
51
|
SessionStarted,
|
|
52
|
+
SkillInvoked,
|
|
53
|
+
SkillScope,
|
|
54
|
+
SkillsDiscovered,
|
|
55
|
+
SkillSignal,
|
|
56
|
+
SkillSummary,
|
|
57
|
+
SkillTracker,
|
|
52
58
|
Stderr,
|
|
53
59
|
StdioMcpServer,
|
|
54
60
|
StreamParser,
|
|
@@ -91,7 +97,7 @@ from .providers.copilot import CopilotProvider
|
|
|
91
97
|
from .providers.gemini import GeminiProvider
|
|
92
98
|
from .providers.opencode import OpencodeProvider
|
|
93
99
|
|
|
94
|
-
__version__ = "0.
|
|
100
|
+
__version__ = "0.9.0"
|
|
95
101
|
|
|
96
102
|
__all__ = [
|
|
97
103
|
"AgentEvent",
|
|
@@ -153,6 +159,12 @@ __all__ = [
|
|
|
153
159
|
"SchemaDialectError",
|
|
154
160
|
"SessionResumeError",
|
|
155
161
|
"SessionStarted",
|
|
162
|
+
"SkillInvoked",
|
|
163
|
+
"SkillScope",
|
|
164
|
+
"SkillSignal",
|
|
165
|
+
"SkillSummary",
|
|
166
|
+
"SkillTracker",
|
|
167
|
+
"SkillsDiscovered",
|
|
156
168
|
"Stderr",
|
|
157
169
|
"StdioMcpServer",
|
|
158
170
|
"StreamParser",
|
|
@@ -23,9 +23,10 @@ from agentshim.core.errors import (
|
|
|
23
23
|
SessionResumeError,
|
|
24
24
|
)
|
|
25
25
|
from agentshim.core.events import RunFinished, RunStarted, compose_event_handlers
|
|
26
|
-
from agentshim.core.profile import McpMechanism, OutputSchemaStyle, SchemaDialect
|
|
26
|
+
from agentshim.core.profile import McpMechanism, OutputSchemaStyle, SchemaDialect, SkillScope
|
|
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
|
|
@@ -113,9 +114,15 @@ class CliAgent:
|
|
|
113
114
|
cwd: str | None = None,
|
|
114
115
|
timeout: float | None = None,
|
|
115
116
|
session_id: str | None = None,
|
|
117
|
+
skill_scope: SkillScope = SkillScope.ALL,
|
|
116
118
|
) -> AgentSession:
|
|
117
|
-
"""Open a conversation whose turns resume one another.
|
|
118
|
-
|
|
119
|
+
"""Open a conversation whose turns resume one another.
|
|
120
|
+
|
|
121
|
+
``skill_scope`` is fixed for the conversation (see ``AgentSession``).
|
|
122
|
+
"""
|
|
123
|
+
return AgentSession(
|
|
124
|
+
self, cwd=cwd, timeout=timeout, session_id=session_id, skill_scope=skill_scope
|
|
125
|
+
)
|
|
119
126
|
|
|
120
127
|
def run(
|
|
121
128
|
self,
|
|
@@ -123,9 +130,11 @@ class CliAgent:
|
|
|
123
130
|
*,
|
|
124
131
|
cwd: str | None = None,
|
|
125
132
|
timeout: float | None = None,
|
|
133
|
+
skill_scope: SkillScope = SkillScope.ALL,
|
|
126
134
|
) -> TurnResult:
|
|
127
135
|
"""Run one turn in a throwaway session."""
|
|
128
|
-
|
|
136
|
+
session = self.start_session(cwd=cwd, timeout=timeout, skill_scope=skill_scope)
|
|
137
|
+
return session.turn(request)
|
|
129
138
|
|
|
130
139
|
|
|
131
140
|
def _discard(message: str) -> None:
|
|
@@ -142,6 +151,7 @@ class AgentSession:
|
|
|
142
151
|
cwd: str | None = None,
|
|
143
152
|
timeout: float | None = None,
|
|
144
153
|
session_id: str | None = None,
|
|
154
|
+
skill_scope: SkillScope = SkillScope.ALL,
|
|
145
155
|
) -> None:
|
|
146
156
|
"""Bind a conversation to one agent, with per-turn defaults.
|
|
147
157
|
|
|
@@ -149,8 +159,15 @@ class AgentSession:
|
|
|
149
159
|
successive turns resume one another. ``cwd`` and ``timeout`` are
|
|
150
160
|
defaults an individual ``TurnRequest`` may override. Passing
|
|
151
161
|
``session_id`` adopts a conversation the provider already has, so the
|
|
152
|
-
first turn resumes rather than starts fresh.
|
|
162
|
+
first turn resumes rather than starts fresh. ``skill_scope`` limits
|
|
163
|
+
which skills every turn's CLI may discover; a scope the provider's
|
|
164
|
+
``profile.skill_scopes`` does not list raises
|
|
165
|
+
``ProviderCapabilityError`` here, before any turn runs.
|
|
153
166
|
"""
|
|
167
|
+
if skill_scope not in agent.profile.skill_scopes:
|
|
168
|
+
msg = f"{agent.profile.name} cannot limit skills to scope {skill_scope.value!r}"
|
|
169
|
+
raise ProviderCapabilityError(msg)
|
|
170
|
+
self.skill_scope = skill_scope
|
|
154
171
|
self._agent = agent
|
|
155
172
|
self._cwd = cwd
|
|
156
173
|
self._timeout = timeout
|
|
@@ -259,6 +276,7 @@ class AgentSession:
|
|
|
259
276
|
mcp_argv=installation.argv,
|
|
260
277
|
extra_args=req.extra_args,
|
|
261
278
|
cwd=cwd,
|
|
279
|
+
skill_scope=self.skill_scope,
|
|
262
280
|
)
|
|
263
281
|
)
|
|
264
282
|
command = CommandRequest(argv=argv, stdin=req.prompt, cwd=cwd, env=env, timeout=timeout)
|
|
@@ -278,8 +296,10 @@ class AgentSession:
|
|
|
278
296
|
agent = self._agent
|
|
279
297
|
handler = agent.event_handler
|
|
280
298
|
resumed = self.session_id is not None
|
|
299
|
+
skills = SkillTracker(self.profile)
|
|
281
300
|
|
|
282
301
|
def emit(event: AgentEvent) -> None:
|
|
302
|
+
skills.on_event(event)
|
|
283
303
|
handler.on_event(event)
|
|
284
304
|
|
|
285
305
|
argv = list(command.argv)
|
|
@@ -305,6 +325,7 @@ class AgentSession:
|
|
|
305
325
|
cost_usd=parsed.cost_usd,
|
|
306
326
|
duration_ms=duration_ms,
|
|
307
327
|
exit_code=result.returncode,
|
|
328
|
+
skills=skills.summary(),
|
|
308
329
|
)
|
|
309
330
|
self.last_result = turn_result
|
|
310
331
|
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,17 @@ 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
|
+
SkillScope,
|
|
62
|
+
SkillSignal,
|
|
63
|
+
)
|
|
55
64
|
from .provider import ArgvContext, McpInstallation, ParsedTurn, Provider, StreamParser
|
|
56
65
|
from .schema import compact_json, dialect_problems, materialize, normalize
|
|
66
|
+
from .skills import SkillSummary, SkillTracker
|
|
57
67
|
from .stream import ToolTracker, parse_json_object
|
|
58
68
|
from .turn import OutputSchema, TurnRequest, TurnResult
|
|
59
69
|
from .usage import ProviderUsage, TokenUsage, TokenWeights, normalized_usage
|
|
@@ -100,6 +110,12 @@ __all__ = [
|
|
|
100
110
|
"SchemaDialectError",
|
|
101
111
|
"SessionResumeError",
|
|
102
112
|
"SessionStarted",
|
|
113
|
+
"SkillInvoked",
|
|
114
|
+
"SkillScope",
|
|
115
|
+
"SkillSignal",
|
|
116
|
+
"SkillSummary",
|
|
117
|
+
"SkillTracker",
|
|
118
|
+
"SkillsDiscovered",
|
|
103
119
|
"Stderr",
|
|
104
120
|
"StdioMcpServer",
|
|
105
121
|
"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,37 @@ 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
|
+
|
|
64
|
+
class SkillScope(Enum):
|
|
65
|
+
"""Which skills a session's CLI may discover.
|
|
66
|
+
|
|
67
|
+
``ALL``: whatever the CLI finds by default, including the user's own
|
|
68
|
+
skills and installed plugins. ``PROJECT``: only the skills under the
|
|
69
|
+
session's working directory (the profile's ``skill_dirs``) plus the
|
|
70
|
+
CLI's built-in ones, so the user running agentshim does not change what
|
|
71
|
+
the agent is offered. A provider declares the scopes it can enforce in
|
|
72
|
+
``ProviderProfile.skill_scopes``; asking for another one is an error,
|
|
73
|
+
never a silent ``ALL``.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
ALL = "all"
|
|
77
|
+
PROJECT = "project"
|
|
78
|
+
|
|
79
|
+
|
|
49
80
|
def _no_container_env() -> Mapping[str, str]:
|
|
50
81
|
"""Empty read-only default: a frozen spec must not carry a mutable one.
|
|
51
82
|
|
|
@@ -90,3 +121,9 @@ class ProviderProfile:
|
|
|
90
121
|
state_root_env: str | None = None
|
|
91
122
|
auth_files: tuple[str, ...] = ()
|
|
92
123
|
mcp_config_file: str | None = None
|
|
124
|
+
#: Whether the stream lists the skills offered (``SkillsDiscovered``).
|
|
125
|
+
skill_discovery: SkillSignal = SkillSignal.NONE
|
|
126
|
+
#: Whether the stream reveals skill loads (``SkillInvoked``).
|
|
127
|
+
skill_invocation: SkillSignal = SkillSignal.NONE
|
|
128
|
+
#: The ``SkillScope`` values a session on this provider may request.
|
|
129
|
+
skill_scopes: frozenset[SkillScope] = frozenset({SkillScope.ALL})
|
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
from dataclasses import dataclass, field
|
|
6
6
|
from typing import TYPE_CHECKING, Any, Protocol
|
|
7
7
|
|
|
8
|
+
from .profile import SkillScope
|
|
8
9
|
from .usage import ProviderUsage
|
|
9
10
|
|
|
10
11
|
if TYPE_CHECKING:
|
|
@@ -37,6 +38,9 @@ class ArgvContext:
|
|
|
37
38
|
#: The directory the CLI will run in, or ``None`` for the executor's own.
|
|
38
39
|
#: Argv never carries it; a provider reads it to validate paths against it.
|
|
39
40
|
cwd: str | None = None
|
|
41
|
+
#: Which skills the CLI may discover; the session has already checked
|
|
42
|
+
#: that the provider's profile supports it.
|
|
43
|
+
skill_scope: SkillScope = SkillScope.ALL
|
|
40
44
|
|
|
41
45
|
|
|
42
46
|
@dataclass(frozen=True)
|
|
@@ -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,14 @@ 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
|
+
SkillScope,
|
|
16
|
+
SkillSignal,
|
|
17
|
+
)
|
|
11
18
|
|
|
12
19
|
from .parser import ClaudeStreamParser
|
|
13
20
|
from .sandbox import SANDBOX_ENV, SandboxConfig, build_settings, resolve_sandbox
|
|
@@ -25,6 +32,17 @@ if TYPE_CHECKING:
|
|
|
25
32
|
MCP_CONFIG_FILENAME = ".mcp.json"
|
|
26
33
|
MCP_SERVER_KEY = "mcpServers"
|
|
27
34
|
|
|
35
|
+
#: ``SkillScope.PROJECT``: load settings only from the workspace's
|
|
36
|
+
#: ``.claude/settings.json`` and ``.claude/settings.local.json`` (plus
|
|
37
|
+
#: ``--settings`` and managed policy, which always apply). Leaving out the
|
|
38
|
+
#: ``user`` source drops ``~/.claude/skills``, the user's plugins (with their
|
|
39
|
+
#: skills, hooks and MCP servers), ``~/.claude/CLAUDE.md`` and the user's
|
|
40
|
+
#: settings (default model, permissions, hooks, ``env``, ``apiKeyHelper``).
|
|
41
|
+
#: Credentials are not settings: OAuth in ``.credentials.json`` and the
|
|
42
|
+
#: ``auth_env_vars`` keep working. Claude Code's built-in skills and claude.ai
|
|
43
|
+
#: account connectors are not user settings and stay.
|
|
44
|
+
PROJECT_SETTING_SOURCES = ("--setting-sources", "project,local")
|
|
45
|
+
|
|
28
46
|
PROFILE = ProviderProfile(
|
|
29
47
|
name="claude",
|
|
30
48
|
display_name="Claude Code",
|
|
@@ -45,6 +63,10 @@ PROFILE = ProviderProfile(
|
|
|
45
63
|
"ANTHROPIC_CUSTOM_HEADERS",
|
|
46
64
|
),
|
|
47
65
|
skill_dirs=(".claude/skills",),
|
|
66
|
+
# ``system/init`` lists ``skills``; the ``Skill`` tool call names one.
|
|
67
|
+
skill_discovery=SkillSignal.STRUCTURED,
|
|
68
|
+
skill_invocation=SkillSignal.STRUCTURED,
|
|
69
|
+
skill_scopes=frozenset({SkillScope.ALL, SkillScope.PROJECT}),
|
|
48
70
|
container_install=(
|
|
49
71
|
"apt-get update && apt-get install -y --no-install-recommends curl ca-certificates",
|
|
50
72
|
"curl -fsSL https://claude.ai/install.sh | bash",
|
|
@@ -108,6 +130,8 @@ class ClaudeProvider:
|
|
|
108
130
|
"stream-json",
|
|
109
131
|
"--verbose",
|
|
110
132
|
]
|
|
133
|
+
if ctx.skill_scope is SkillScope.PROJECT:
|
|
134
|
+
argv += PROJECT_SETTING_SOURCES
|
|
111
135
|
if ctx.model:
|
|
112
136
|
argv += ["--model", ctx.model]
|
|
113
137
|
if ctx.reasoning_effort:
|
|
@@ -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,12 +13,15 @@ from agentshim.core.profile import (
|
|
|
13
13
|
OutputSchemaStyle,
|
|
14
14
|
ProviderProfile,
|
|
15
15
|
SchemaDialect,
|
|
16
|
+
SkillScope,
|
|
17
|
+
SkillSignal,
|
|
16
18
|
)
|
|
17
19
|
|
|
18
20
|
from ._toml import toml_array, toml_str, unescape_toml
|
|
19
21
|
from .parser import CodexStreamParser
|
|
20
22
|
from .rules import RULES_FILENAME
|
|
21
23
|
from .sandbox import CodexSandboxConfig, resolve_sandbox, sandbox_overrides
|
|
24
|
+
from .skills import project_scope_overrides
|
|
22
25
|
|
|
23
26
|
if TYPE_CHECKING:
|
|
24
27
|
from collections.abc import Callable, Mapping, Sequence
|
|
@@ -75,6 +78,10 @@ PROFILE = ProviderProfile(
|
|
|
75
78
|
),
|
|
76
79
|
auth_env_vars=("OPENAI_API_KEY", "OPENAI_BASE_URL"),
|
|
77
80
|
skill_dirs=(".agents/skills",),
|
|
81
|
+
# ``exec --json`` lists no skills; a load is inferred from a shell read
|
|
82
|
+
# of a ``SKILL.md`` (``skills.py``).
|
|
83
|
+
skill_invocation=SkillSignal.INFERRED,
|
|
84
|
+
skill_scopes=frozenset({SkillScope.ALL, SkillScope.PROJECT}),
|
|
78
85
|
container_install=(_NODE_INSTALL, _CODEX_INSTALL),
|
|
79
86
|
# Documented Codex CLI variable that relocates ~/.codex (``codex --help``:
|
|
80
87
|
# "Layer $CODEX_HOME/<name>.config.toml on top of the base user config";
|
|
@@ -126,6 +133,8 @@ class CodexProvider:
|
|
|
126
133
|
if ctx.model:
|
|
127
134
|
argv += ["--model", ctx.model]
|
|
128
135
|
argv += _shell_path_config(ctx.env)
|
|
136
|
+
if ctx.skill_scope is SkillScope.PROJECT:
|
|
137
|
+
argv += project_scope_overrides(ctx.env)
|
|
129
138
|
if ctx.reasoning_effort:
|
|
130
139
|
argv += ["--config", f"model_reasoning_effort={toml_str(ctx.reasoning_effort)}"]
|
|
131
140
|
argv += list(ctx.mcp_argv)
|
|
@@ -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,86 @@
|
|
|
1
|
+
"""Codex skills: recognize a load in a shell command, and hide the user's.
|
|
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 os
|
|
13
|
+
import re
|
|
14
|
+
from typing import TYPE_CHECKING
|
|
15
|
+
|
|
16
|
+
from agentshim.core.events import SkillInvoked
|
|
17
|
+
|
|
18
|
+
from ._toml import toml_str
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from collections.abc import Mapping
|
|
22
|
+
|
|
23
|
+
#: A path ending in ``skills/[<group>/...]<name>/SKILL.md``, as one shell word.
|
|
24
|
+
_SKILL_FILE = re.compile(
|
|
25
|
+
r"(?<![^\s'\"=])(?P<path>(?:[^\s'\"]*/)?skills/(?:[^\s'\"/]+/)*?(?P<name>[^\s'\"/]+)/SKILL\.md)(?![^\s'\";|&)])"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def skill_reads(command: str, tool_id: str | None) -> list[SkillInvoked]:
|
|
30
|
+
"""Return one ``SkillInvoked`` per ``SKILL.md`` the command names, in order."""
|
|
31
|
+
return [
|
|
32
|
+
SkillInvoked(name=match["name"], source_path=match["path"], tool_id=tool_id)
|
|
33
|
+
for match in _SKILL_FILE.finditer(command)
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
#: Codex's own bundled skills, which it re-extracts into every home.
|
|
38
|
+
_SYSTEM_SKILLS = ".system"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def user_skill_files(env: Mapping[str, str]) -> list[str]:
|
|
42
|
+
"""Every user-installed ``SKILL.md`` the CLI run with *env* would offer.
|
|
43
|
+
|
|
44
|
+
Codex reads user skills from ``$CODEX_HOME/skills`` (default
|
|
45
|
+
``~/.codex/skills``) and ``~/.agents/skills``, beside the workspace's own
|
|
46
|
+
``.agents/skills``. ``$CODEX_HOME/skills/.system`` holds Codex's bundled
|
|
47
|
+
skills and is not the user's. Paths are listed as found and, when a
|
|
48
|
+
symlink is involved, also resolved, since either spelling may be the one
|
|
49
|
+
Codex matches against. The scan runs where agentshim runs, so a CLI in a
|
|
50
|
+
container is only covered when the container shares this home.
|
|
51
|
+
"""
|
|
52
|
+
home = env.get("HOME") or os.path.expanduser("~") # noqa: PTH111 - a str contract, not a Path
|
|
53
|
+
codex_home = env.get("CODEX_HOME") or os.path.join(home, ".codex") # noqa: PTH118
|
|
54
|
+
roots = [os.path.join(codex_home, "skills"), os.path.join(home, ".agents", "skills")] # noqa: PTH118
|
|
55
|
+
found: list[str] = []
|
|
56
|
+
for root in roots:
|
|
57
|
+
for directory, subdirs, files in os.walk(root, followlinks=True):
|
|
58
|
+
if directory == root and _SYSTEM_SKILLS in subdirs:
|
|
59
|
+
subdirs.remove(_SYSTEM_SKILLS)
|
|
60
|
+
subdirs.sort()
|
|
61
|
+
if "SKILL.md" in files:
|
|
62
|
+
path = os.path.join(directory, "SKILL.md") # noqa: PTH118
|
|
63
|
+
found.append(path)
|
|
64
|
+
resolved = os.path.realpath(path)
|
|
65
|
+
if resolved != path:
|
|
66
|
+
found.append(resolved)
|
|
67
|
+
return list(dict.fromkeys(found))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def project_scope_overrides(env: Mapping[str, str]) -> list[str]:
|
|
71
|
+
"""``--config`` flags that leave Codex only the workspace's skills.
|
|
72
|
+
|
|
73
|
+
Plugins are switched off as a feature (their skills, MCP servers and
|
|
74
|
+
apps go with them), and each user skill is disabled by path through
|
|
75
|
+
``skills.config``. The override replaces any ``skills.config`` in the
|
|
76
|
+
user's ``config.toml``, which is user policy this scope excludes anyway.
|
|
77
|
+
"""
|
|
78
|
+
entries = ", ".join(
|
|
79
|
+
f"{{path = {toml_str(path)}, enabled = false}}" for path in user_skill_files(env)
|
|
80
|
+
)
|
|
81
|
+
return [
|
|
82
|
+
"--config",
|
|
83
|
+
"features.plugins=false",
|
|
84
|
+
"--config",
|
|
85
|
+
f"skills.config=[{entries}]",
|
|
86
|
+
]
|
|
@@ -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
|