kodelet-sdk 0.2.2__tar.gz → 0.3.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.
- {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/PKG-INFO +11 -42
- {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/README.md +10 -41
- kodelet_sdk-0.3.0/VERSION.txt +1 -0
- {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/src/kodelet_sdk/__init__.py +1 -6
- {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/src/kodelet_sdk/agent/client.py +4 -2
- {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/src/kodelet_sdk/agent/types.py +2 -1
- {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/src/kodelet_sdk/api.py +0 -14
- {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/src/kodelet_sdk/context.py +0 -3
- {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/src/kodelet_sdk/execution.py +1 -29
- {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/tests/test_agent.py +39 -5
- {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/tests/test_api.py +11 -0
- kodelet_sdk-0.3.0/tests/test_execution.py +66 -0
- {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/tests/test_runtime.py +30 -45
- kodelet_sdk-0.2.2/VERSION.txt +0 -1
- kodelet_sdk-0.2.2/src/kodelet_sdk/child.py +0 -226
- kodelet_sdk-0.2.2/tests/test_child.py +0 -498
- {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/.github/workflows/ci.yml +0 -0
- {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/.github/workflows/release.yml +0 -0
- {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/.gitignore +0 -0
- {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/AGENTS.md +0 -0
- {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/LICENSE +0 -0
- {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/Makefile +0 -0
- {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/examples/review/kodelet-extension-review +0 -0
- {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/examples/review/reviewer-prompt.md +0 -0
- {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/examples/sdk/README.md +0 -0
- {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/examples/sdk/basic-agent-session +0 -0
- {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/examples/sdk/inline-extension-session +0 -0
- {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/examples/sdk/streaming-agent-session +0 -0
- {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/examples/workspace/kodelet-extension-workspace +0 -0
- {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/pyproject.toml +0 -0
- {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/src/kodelet_sdk/_utils.py +0 -0
- {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/src/kodelet_sdk/agent/__init__.py +0 -0
- {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/src/kodelet_sdk/agent/relay.py +0 -0
- {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/src/kodelet_sdk/agent/rpc.py +0 -0
- {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/src/kodelet_sdk/agent/session.py +0 -0
- {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/src/kodelet_sdk/agent/transport.py +0 -0
- {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/src/kodelet_sdk/py.typed +0 -0
- {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/src/kodelet_sdk/runtime.py +0 -0
- {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/src/kodelet_sdk/schemas.py +0 -0
- {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/src/kodelet_sdk/task_progress.py +0 -0
- {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/src/kodelet_sdk/template.py +0 -0
- {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/src/kodelet_sdk/test_harness.py +0 -0
- {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/tests/fixtures/workspace_policy_extension.py +0 -0
- {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/tests/test_acp_transport.py +0 -0
- {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/tests/test_examples.py +0 -0
- {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/tests/test_persistent_ui.py +0 -0
- {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/tests/test_task_progress.py +0 -0
- {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: kodelet-sdk
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Python SDK for authoring Kodelet extensions
|
|
5
5
|
Project-URL: Repository, https://github.com/jingkaihe/kodelet
|
|
6
6
|
Author: Kodelet
|
|
@@ -108,7 +108,7 @@ Session options include:
|
|
|
108
108
|
| `cwd` | Working directory on the runner |
|
|
109
109
|
| `resume` | An existing conversation ID |
|
|
110
110
|
|
|
111
|
-
Inline settings cannot include provider secrets, endpoints, or local prompt paths.
|
|
111
|
+
Pass an `ExecutionOptions` instance directly as `create_session(options=...)` for model settings, execution limits, and tool selection. The optional `profile="work"` selects a model profile already configured on the daemon; omit it to use the daemon's default. Inline settings cannot include provider secrets, endpoints, or local prompt paths.
|
|
112
112
|
|
|
113
113
|
### Inline extensions
|
|
114
114
|
|
|
@@ -149,7 +149,9 @@ if __name__ == "__main__":
|
|
|
149
149
|
|
|
150
150
|
Inline extensions require ACP session-extension protocol v1; older hosts fail with a clear error. On resume, reattach extensions in the same order. Callbacks are not saved or replayed, and pending work is cancelled when the channel or session closes.
|
|
151
151
|
|
|
152
|
-
|
|
152
|
+
To customize the prompt, attach an inline `agent.init` handler that returns a `systemPrompt` patch and leave extensions enabled. Prompt hooks work alongside typed session options.
|
|
153
|
+
|
|
154
|
+
Host calls such as `ctx.update()` and `ctx.fork_conversation()` go to the runner. File, process, and storage helpers remain local. Legacy `extension_transport="unix"` and `"tcp"` options are accepted but ignored; ACP manages the connection.
|
|
153
155
|
|
|
154
156
|
### Steering
|
|
155
157
|
|
|
@@ -179,7 +181,6 @@ Create an `Extension(name=..., version=...)`, then register synchronous or async
|
|
|
179
181
|
| `@ext.command(...)` | Slash command or recipe, with optional aliases |
|
|
180
182
|
| `@ext.on(event, ...)` | Lifecycle handler, such as `session.start`, `tool.call`, or `agent.end` |
|
|
181
183
|
| `@ext.shortcut(...)` | Native TUI keyboard shortcut |
|
|
182
|
-
| `ext.register_profile(...)` | Child execution preset |
|
|
183
184
|
| `ext.run_sync()` / `await ext.run()` | Serve an executable extension over stdio |
|
|
184
185
|
|
|
185
186
|
### Tool results and progress
|
|
@@ -292,54 +293,22 @@ Handlers receive `ctx` with call metadata and these helpers:
|
|
|
292
293
|
- `ctx.process.exec(...)` and `ctx.process.spawn(...)` for async process execution.
|
|
293
294
|
- `ctx.env.get(...)` for environment access.
|
|
294
295
|
- `ctx.log.debug/info/warn/error(...)` for JSON logs to stderr.
|
|
295
|
-
- `ctx.
|
|
296
|
+
- `await ctx.fork_conversation(name=...)` to snapshot the active tool's conversation for an ACP session.
|
|
296
297
|
- `await ctx.acquire_background_task(...)` to keep runtime resources alive after a handler returns.
|
|
297
298
|
- `ctx.ui.input/confirm/select/notify(...)` for host UI reverse-RPC calls.
|
|
298
299
|
- `ctx.ui.append_transcript(...)`, `ctx.ui.set_widget(...)`, and `ctx.ui.open_surface(...)` for capability-gated persistent native-TUI content.
|
|
299
300
|
|
|
300
|
-
###
|
|
301
|
-
|
|
302
|
-
Use `ctx.children` for agent work inside a tool, rather than creating a nested `Client`. Register a preset on the extension, then start a child with that preset:
|
|
303
|
-
|
|
304
|
-
```python
|
|
305
|
-
class TaskInput(BaseModel):
|
|
306
|
-
task: str
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
ext.register_profile({
|
|
310
|
-
"name": "code_search",
|
|
311
|
-
"options": {
|
|
312
|
-
"allowed_tools": ["file_read", "grep_tool", "glob_tool"],
|
|
313
|
-
"no_extensions": True,
|
|
314
|
-
"no_skills": True,
|
|
315
|
-
"enable_fs_search_tools": True,
|
|
316
|
-
"max_turns": 3,
|
|
317
|
-
},
|
|
318
|
-
})
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
@ext.tool("code_search", description="Search the repository", input_schema=TaskInput)
|
|
322
|
-
async def code_search(input: TaskInput, ctx: ToolContext) -> str:
|
|
323
|
-
child = await ctx.children.start(profile="code_search", message=input.task)
|
|
324
|
-
result = await child.wait(on_event=lambda event: ctx.update(event.get("text") or event["kind"]))
|
|
325
|
-
return result["output"]
|
|
326
|
-
```
|
|
327
|
-
|
|
328
|
-
- `read()` checks progress; `cancel()` cancels only that child. Cancelling `wait()` also cancels the child.
|
|
329
|
-
- Children start with fresh context. Use `context_mode="fork"` inside the active tool handler to copy the parent's history.
|
|
330
|
-
- Use `resume=child.conversation_id` for a follow-up. It creates a new run; old handles still target the old run. Resume cannot be combined with fork.
|
|
331
|
-
- `child.steer(message)` queues guidance without starting a turn. Like session steering, it returns `injected` or `promptRequired`.
|
|
332
|
-
- Child settings cannot expand the parent's permissions or limits. `system_prompt_path` in a preset is resolved on the runner.
|
|
301
|
+
### Agent work inside tools
|
|
333
302
|
|
|
334
|
-
|
|
303
|
+
Use `Client.create_session(options=ExecutionOptions(...))` with normal client credentials and server/runner targeting. Attach `TaskProgress` for activity updates and await client/progress cleanup on every exit path. A cancelled response is not success, even with partial text.
|
|
335
304
|
|
|
336
|
-
|
|
305
|
+
Sessions start fresh. For inherited context, call `ctx.fork_conversation(name="worker-name")` inside the active tool, then pass the returned ID as `resume`. `inherit_context` remains unsupported. Reuse or resume the session for follow-ups; steering only affects a running turn.
|
|
337
306
|
|
|
338
307
|
### Background work
|
|
339
308
|
|
|
340
|
-
|
|
309
|
+
To let extension work outlive its handler, acquire a lease with `await ctx.acquire_background_task(...)` while the handler is active. Retain and manage the background task yourself, close any ACP client it owns, and release the lease after the work and final UI updates finish. A lease keeps extension resources alive; it does not authorize ACP sessions or keep a completed tool's `ctx.update()` channel open.
|
|
341
310
|
|
|
342
|
-
Runner leases last at most one hour and end on release, cancellation, or runner/extension shutdown. They keep resources alive, not Python task state
|
|
311
|
+
Runner leases last at most one hour and end on release, cancellation, or runner/extension shutdown. They keep resources alive, not Python task state; background tasks must be recreated after a restart.
|
|
343
312
|
|
|
344
313
|
### User input
|
|
345
314
|
|
|
@@ -95,7 +95,7 @@ Session options include:
|
|
|
95
95
|
| `cwd` | Working directory on the runner |
|
|
96
96
|
| `resume` | An existing conversation ID |
|
|
97
97
|
|
|
98
|
-
Inline settings cannot include provider secrets, endpoints, or local prompt paths.
|
|
98
|
+
Pass an `ExecutionOptions` instance directly as `create_session(options=...)` for model settings, execution limits, and tool selection. The optional `profile="work"` selects a model profile already configured on the daemon; omit it to use the daemon's default. Inline settings cannot include provider secrets, endpoints, or local prompt paths.
|
|
99
99
|
|
|
100
100
|
### Inline extensions
|
|
101
101
|
|
|
@@ -136,7 +136,9 @@ if __name__ == "__main__":
|
|
|
136
136
|
|
|
137
137
|
Inline extensions require ACP session-extension protocol v1; older hosts fail with a clear error. On resume, reattach extensions in the same order. Callbacks are not saved or replayed, and pending work is cancelled when the channel or session closes.
|
|
138
138
|
|
|
139
|
-
|
|
139
|
+
To customize the prompt, attach an inline `agent.init` handler that returns a `systemPrompt` patch and leave extensions enabled. Prompt hooks work alongside typed session options.
|
|
140
|
+
|
|
141
|
+
Host calls such as `ctx.update()` and `ctx.fork_conversation()` go to the runner. File, process, and storage helpers remain local. Legacy `extension_transport="unix"` and `"tcp"` options are accepted but ignored; ACP manages the connection.
|
|
140
142
|
|
|
141
143
|
### Steering
|
|
142
144
|
|
|
@@ -166,7 +168,6 @@ Create an `Extension(name=..., version=...)`, then register synchronous or async
|
|
|
166
168
|
| `@ext.command(...)` | Slash command or recipe, with optional aliases |
|
|
167
169
|
| `@ext.on(event, ...)` | Lifecycle handler, such as `session.start`, `tool.call`, or `agent.end` |
|
|
168
170
|
| `@ext.shortcut(...)` | Native TUI keyboard shortcut |
|
|
169
|
-
| `ext.register_profile(...)` | Child execution preset |
|
|
170
171
|
| `ext.run_sync()` / `await ext.run()` | Serve an executable extension over stdio |
|
|
171
172
|
|
|
172
173
|
### Tool results and progress
|
|
@@ -279,54 +280,22 @@ Handlers receive `ctx` with call metadata and these helpers:
|
|
|
279
280
|
- `ctx.process.exec(...)` and `ctx.process.spawn(...)` for async process execution.
|
|
280
281
|
- `ctx.env.get(...)` for environment access.
|
|
281
282
|
- `ctx.log.debug/info/warn/error(...)` for JSON logs to stderr.
|
|
282
|
-
- `ctx.
|
|
283
|
+
- `await ctx.fork_conversation(name=...)` to snapshot the active tool's conversation for an ACP session.
|
|
283
284
|
- `await ctx.acquire_background_task(...)` to keep runtime resources alive after a handler returns.
|
|
284
285
|
- `ctx.ui.input/confirm/select/notify(...)` for host UI reverse-RPC calls.
|
|
285
286
|
- `ctx.ui.append_transcript(...)`, `ctx.ui.set_widget(...)`, and `ctx.ui.open_surface(...)` for capability-gated persistent native-TUI content.
|
|
286
287
|
|
|
287
|
-
###
|
|
288
|
-
|
|
289
|
-
Use `ctx.children` for agent work inside a tool, rather than creating a nested `Client`. Register a preset on the extension, then start a child with that preset:
|
|
290
|
-
|
|
291
|
-
```python
|
|
292
|
-
class TaskInput(BaseModel):
|
|
293
|
-
task: str
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
ext.register_profile({
|
|
297
|
-
"name": "code_search",
|
|
298
|
-
"options": {
|
|
299
|
-
"allowed_tools": ["file_read", "grep_tool", "glob_tool"],
|
|
300
|
-
"no_extensions": True,
|
|
301
|
-
"no_skills": True,
|
|
302
|
-
"enable_fs_search_tools": True,
|
|
303
|
-
"max_turns": 3,
|
|
304
|
-
},
|
|
305
|
-
})
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
@ext.tool("code_search", description="Search the repository", input_schema=TaskInput)
|
|
309
|
-
async def code_search(input: TaskInput, ctx: ToolContext) -> str:
|
|
310
|
-
child = await ctx.children.start(profile="code_search", message=input.task)
|
|
311
|
-
result = await child.wait(on_event=lambda event: ctx.update(event.get("text") or event["kind"]))
|
|
312
|
-
return result["output"]
|
|
313
|
-
```
|
|
314
|
-
|
|
315
|
-
- `read()` checks progress; `cancel()` cancels only that child. Cancelling `wait()` also cancels the child.
|
|
316
|
-
- Children start with fresh context. Use `context_mode="fork"` inside the active tool handler to copy the parent's history.
|
|
317
|
-
- Use `resume=child.conversation_id` for a follow-up. It creates a new run; old handles still target the old run. Resume cannot be combined with fork.
|
|
318
|
-
- `child.steer(message)` queues guidance without starting a turn. Like session steering, it returns `injected` or `promptRequired`.
|
|
319
|
-
- Child settings cannot expand the parent's permissions or limits. `system_prompt_path` in a preset is resolved on the runner.
|
|
288
|
+
### Agent work inside tools
|
|
320
289
|
|
|
321
|
-
|
|
290
|
+
Use `Client.create_session(options=ExecutionOptions(...))` with normal client credentials and server/runner targeting. Attach `TaskProgress` for activity updates and await client/progress cleanup on every exit path. A cancelled response is not success, even with partial text.
|
|
322
291
|
|
|
323
|
-
|
|
292
|
+
Sessions start fresh. For inherited context, call `ctx.fork_conversation(name="worker-name")` inside the active tool, then pass the returned ID as `resume`. `inherit_context` remains unsupported. Reuse or resume the session for follow-ups; steering only affects a running turn.
|
|
324
293
|
|
|
325
294
|
### Background work
|
|
326
295
|
|
|
327
|
-
|
|
296
|
+
To let extension work outlive its handler, acquire a lease with `await ctx.acquire_background_task(...)` while the handler is active. Retain and manage the background task yourself, close any ACP client it owns, and release the lease after the work and final UI updates finish. A lease keeps extension resources alive; it does not authorize ACP sessions or keep a completed tool's `ctx.update()` channel open.
|
|
328
297
|
|
|
329
|
-
Runner leases last at most one hour and end on release, cancellation, or runner/extension shutdown. They keep resources alive, not Python task state
|
|
298
|
+
Runner leases last at most one hour and end on release, cancellation, or runner/extension shutdown. They keep resources alive, not Python task state; background tasks must be recreated after a restart.
|
|
330
299
|
|
|
331
300
|
### User input
|
|
332
301
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.3.0
|
|
@@ -65,7 +65,6 @@ from .api import (
|
|
|
65
65
|
define_extension,
|
|
66
66
|
on,
|
|
67
67
|
)
|
|
68
|
-
from .child import ChildClient, ChildExecution, ChildSteerResult
|
|
69
68
|
from .context import (
|
|
70
69
|
BackgroundTaskLease,
|
|
71
70
|
CommandContext,
|
|
@@ -115,7 +114,7 @@ from .context import (
|
|
|
115
114
|
run_with_host_rpc_client,
|
|
116
115
|
set_active_host_rpc_client,
|
|
117
116
|
)
|
|
118
|
-
from .execution import ExecutionOptions
|
|
117
|
+
from .execution import ExecutionOptions
|
|
119
118
|
from .runtime import run_extension
|
|
120
119
|
from .schemas import JSONSchema, ToolInputSchema
|
|
121
120
|
from .task_progress import (
|
|
@@ -153,9 +152,6 @@ __all__ = [
|
|
|
153
152
|
"BackgroundTaskLease",
|
|
154
153
|
"BaseModel",
|
|
155
154
|
"BridgeTransport",
|
|
156
|
-
"ChildClient",
|
|
157
|
-
"ChildExecution",
|
|
158
|
-
"ChildSteerResult",
|
|
159
155
|
"Client",
|
|
160
156
|
"ClientOptions",
|
|
161
157
|
"CommandContext",
|
|
@@ -176,7 +172,6 @@ __all__ = [
|
|
|
176
172
|
"EventResult",
|
|
177
173
|
"ExecResult",
|
|
178
174
|
"ExecutionOptions",
|
|
179
|
-
"ExecutionProfile",
|
|
180
175
|
"Extension",
|
|
181
176
|
"ExtensionEvent",
|
|
182
177
|
"ExtensionHost",
|
|
@@ -79,8 +79,10 @@ class Client:
|
|
|
79
79
|
|
|
80
80
|
merged_options: dict[str, Any] = {**dict(session_options or {}), **kwargs}
|
|
81
81
|
if merged_options.get("inherit_context") is not None:
|
|
82
|
-
raise ValueError(
|
|
83
|
-
|
|
82
|
+
raise ValueError(
|
|
83
|
+
"inherit_context is unsupported; call ctx.fork_conversation() "
|
|
84
|
+
"and pass the returned conversation ID as resume"
|
|
85
|
+
)
|
|
84
86
|
extensions = tuple(
|
|
85
87
|
cast(Sequence[Entrypoint | Extension], merged_options.get("extensions") or ())
|
|
86
88
|
)
|
|
@@ -78,7 +78,8 @@ class CreateSessionOptions(TypedDict, total=False):
|
|
|
78
78
|
protocol; supply them again on resume in the same order. ``ui`` services
|
|
79
79
|
input, confirm, select and notify requests locally when handlers are supplied.
|
|
80
80
|
``extension_transport`` accepts legacy unix/tcp values but is now a no-op.
|
|
81
|
-
``inherit_context`` remains unsupported;
|
|
81
|
+
``inherit_context`` remains unsupported; call ``ctx.fork_conversation()``
|
|
82
|
+
explicitly and pass its returned conversation ID as ``resume`` instead.
|
|
82
83
|
"""
|
|
83
84
|
|
|
84
85
|
profile: str | Profile | ProfileInput
|
|
@@ -36,7 +36,6 @@ from .context import (
|
|
|
36
36
|
create_shortcut_context,
|
|
37
37
|
create_tool_context,
|
|
38
38
|
)
|
|
39
|
-
from .execution import ExecutionProfile
|
|
40
39
|
from .schemas import SchemaAdapter, SchemaLike, infer_schema_from_callable
|
|
41
40
|
|
|
42
41
|
_MISSING = object()
|
|
@@ -327,7 +326,6 @@ class Extension:
|
|
|
327
326
|
if version is not None:
|
|
328
327
|
self._metadata["version"] = version
|
|
329
328
|
self._tools: dict[str, ToolRegistration] = {}
|
|
330
|
-
self._profiles: dict[str, dict[str, Any]] = {}
|
|
331
329
|
self._commands_by_name: dict[str, CommandRegistration] = {}
|
|
332
330
|
self._command_registrations: list[CommandRegistration] = []
|
|
333
331
|
self._shortcuts: dict[str, ShortcutRegistration] = {}
|
|
@@ -650,16 +648,6 @@ class Extension:
|
|
|
650
648
|
return decorator(cast(HandlerT, handler))
|
|
651
649
|
return decorator
|
|
652
650
|
|
|
653
|
-
def register_profile(self, profile: ExecutionProfile | Mapping[str, Any]) -> None:
|
|
654
|
-
"""Register an extension-scoped child preset without daemon YAML setup."""
|
|
655
|
-
snapshot = ExecutionProfile.model_validate(profile).to_wire()
|
|
656
|
-
name = str(snapshot["name"])
|
|
657
|
-
if name in self._profiles:
|
|
658
|
-
raise ValueError(f"Duplicate execution preset: {name}")
|
|
659
|
-
if len(self._profiles) >= 32:
|
|
660
|
-
raise ValueError("An extension supports at most 32 execution presets")
|
|
661
|
-
self._profiles[name] = snapshot
|
|
662
|
-
|
|
663
651
|
def initialize(self, params: Mapping[str, Any]) -> dict[str, Any]:
|
|
664
652
|
"""Handle Kodelet's ``extension.initialize`` JSON-RPC request.
|
|
665
653
|
|
|
@@ -687,8 +675,6 @@ class Extension:
|
|
|
687
675
|
}
|
|
688
676
|
if version := self._metadata.get("version"):
|
|
689
677
|
result["version"] = version
|
|
690
|
-
if self._profiles:
|
|
691
|
-
result["profiles"] = json_clone(list(self._profiles.values()))
|
|
692
678
|
return result
|
|
693
679
|
|
|
694
680
|
async def execute_tool(self, params: Mapping[str, Any]) -> dict[str, Any]:
|
|
@@ -14,8 +14,6 @@ from dataclasses import dataclass
|
|
|
14
14
|
from pathlib import Path
|
|
15
15
|
from typing import Any, Literal, NotRequired, Protocol, Required, TypeAlias, TypedDict, cast
|
|
16
16
|
|
|
17
|
-
from .child import ChildClient
|
|
18
|
-
|
|
19
17
|
CommandFlagValue: TypeAlias = str | bool | list[str]
|
|
20
18
|
|
|
21
19
|
|
|
@@ -1447,7 +1445,6 @@ class SharedContext:
|
|
|
1447
1445
|
self.env = EnvContext()
|
|
1448
1446
|
self.log = LogContext(_optional_str(extension.get("id")))
|
|
1449
1447
|
self._host_rpc_client = _current_host_rpc_client()
|
|
1450
|
-
self.children = ChildClient(self._host_rpc_client)
|
|
1451
1448
|
self._background_tasks_enabled = _runtime_capability_supported(
|
|
1452
1449
|
init,
|
|
1453
1450
|
"backgroundTasks",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Typed, credential-free ACP execution options."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
@@ -62,31 +62,3 @@ def execution_args(options: ExecutionOptions) -> list[str]:
|
|
|
62
62
|
encoded = str(value)
|
|
63
63
|
args.append(f"--{flag}={encoded}")
|
|
64
64
|
return args
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
class ExecutionProfile(BaseModel):
|
|
68
|
-
"""Extension-owned preset; prompt paths are resolved on the runner."""
|
|
69
|
-
|
|
70
|
-
model_config = ConfigDict(extra="forbid", strict=True, alias_generator=to_camel,
|
|
71
|
-
populate_by_name=True)
|
|
72
|
-
name: str = Field(min_length=1, max_length=128, pattern=r"^[^/\\\x00]+$")
|
|
73
|
-
options: ExecutionOptions | None = None
|
|
74
|
-
system_prompt_path: str | None = Field(default=None, max_length=8192)
|
|
75
|
-
system_prompt: str | None = Field(default=None, max_length=256 * 1024)
|
|
76
|
-
|
|
77
|
-
@model_validator(mode="before")
|
|
78
|
-
@classmethod
|
|
79
|
-
def reject_null(cls, value: Any) -> Any:
|
|
80
|
-
if isinstance(value, Mapping) and any(item is None for item in value.values()):
|
|
81
|
-
raise ValueError("Omit absent preset fields instead of null")
|
|
82
|
-
return value
|
|
83
|
-
|
|
84
|
-
@model_validator(mode="after")
|
|
85
|
-
def check_prompt(self) -> ExecutionProfile:
|
|
86
|
-
if self.system_prompt and self.system_prompt_path:
|
|
87
|
-
raise ValueError("Use prompt content or a prompt path, not both")
|
|
88
|
-
return self
|
|
89
|
-
|
|
90
|
-
def to_wire(self) -> dict[str, Any]:
|
|
91
|
-
"""Return the serializable registration for extension.initialize."""
|
|
92
|
-
return self.model_dump(by_alias=True, exclude_none=True)
|
|
@@ -15,6 +15,7 @@ from kodelet_sdk import (
|
|
|
15
15
|
BridgeTransport,
|
|
16
16
|
Client,
|
|
17
17
|
CreateSessionOptions,
|
|
18
|
+
ExecutionOptions,
|
|
18
19
|
Extension,
|
|
19
20
|
HostRPCError,
|
|
20
21
|
Profile,
|
|
@@ -295,6 +296,39 @@ def test_agent_package_preserves_public_reexports() -> None:
|
|
|
295
296
|
assert client_module.Client is Client
|
|
296
297
|
|
|
297
298
|
|
|
299
|
+
@pytest.mark.asyncio
|
|
300
|
+
async def test_session_preserves_typed_execution_options_with_named_profile() -> None:
|
|
301
|
+
calls: list[list[str]] = []
|
|
302
|
+
|
|
303
|
+
def spawn(_command: str, args: Sequence[str], _options: SpawnOptions) -> FakeACPProcess:
|
|
304
|
+
calls.append(list(args))
|
|
305
|
+
return FakeACPProcess()
|
|
306
|
+
|
|
307
|
+
client = Client(spawn=spawn)
|
|
308
|
+
try:
|
|
309
|
+
await client.create_session(
|
|
310
|
+
profile="work",
|
|
311
|
+
options=ExecutionOptions(
|
|
312
|
+
max_turns=0,
|
|
313
|
+
no_tools=False,
|
|
314
|
+
allowed_tools=["file_read", "grep_tool", "glob_tool"],
|
|
315
|
+
allowed_commands=[],
|
|
316
|
+
enableFSSearchTools=True,
|
|
317
|
+
),
|
|
318
|
+
)
|
|
319
|
+
assert calls == [[
|
|
320
|
+
"acp",
|
|
321
|
+
"--max-turns=0",
|
|
322
|
+
"--no-tools=false",
|
|
323
|
+
'--allowed-tools="file_read","grep_tool","glob_tool"',
|
|
324
|
+
"--allowed-commands=",
|
|
325
|
+
"--enable-fs-search-tools=true",
|
|
326
|
+
"--profile=work",
|
|
327
|
+
]]
|
|
328
|
+
finally:
|
|
329
|
+
await client.close()
|
|
330
|
+
|
|
331
|
+
|
|
298
332
|
def test_profile_maps_early_profiler_spelling_and_nested_config() -> None:
|
|
299
333
|
profile = Profile(
|
|
300
334
|
{
|
|
@@ -390,12 +424,12 @@ async def test_inline_options_do_not_rewrite_client_environment(
|
|
|
390
424
|
|
|
391
425
|
|
|
392
426
|
@pytest.mark.asyncio
|
|
393
|
-
async def
|
|
427
|
+
async def test_session_rejects_implicit_inherited_context_before_spawn() -> None:
|
|
394
428
|
processes: list[FakeACPProcess] = []
|
|
395
429
|
|
|
396
430
|
class InheritedContext:
|
|
397
431
|
async def fork_conversation(self) -> str:
|
|
398
|
-
raise AssertionError("must not fork
|
|
432
|
+
raise AssertionError("must not fork implicitly")
|
|
399
433
|
|
|
400
434
|
def spawn(_command: str, _args: Sequence[str], _options: SpawnOptions) -> FakeACPProcess:
|
|
401
435
|
process = FakeACPProcess()
|
|
@@ -403,7 +437,7 @@ async def test_session_rejects_unscoped_inherited_context_before_spawn() -> None
|
|
|
403
437
|
return process
|
|
404
438
|
|
|
405
439
|
client = Client(spawn=spawn)
|
|
406
|
-
with pytest.raises(ValueError, match=r"ctx\.
|
|
440
|
+
with pytest.raises(ValueError, match=r"ctx\.fork_conversation\(\).*resume"):
|
|
407
441
|
await client.create_session(inherit_context=cast(Any, InheritedContext()))
|
|
408
442
|
assert processes == []
|
|
409
443
|
await client.close()
|
|
@@ -1345,7 +1379,7 @@ async def test_session_rejects_resume_with_inherited_context() -> None:
|
|
|
1345
1379
|
return "forked-conversation"
|
|
1346
1380
|
|
|
1347
1381
|
client = Client()
|
|
1348
|
-
with pytest.raises(ValueError, match=r"ctx\.
|
|
1382
|
+
with pytest.raises(ValueError, match=r"ctx\.fork_conversation\(\).*resume"):
|
|
1349
1383
|
await client.create_session(
|
|
1350
1384
|
resume="existing-conversation",
|
|
1351
1385
|
inherit_context=cast(Any, InheritedContext()),
|
|
@@ -1359,7 +1393,7 @@ async def test_session_rejects_profile_with_inherited_context() -> None:
|
|
|
1359
1393
|
return "forked-conversation"
|
|
1360
1394
|
|
|
1361
1395
|
client = Client()
|
|
1362
|
-
with pytest.raises(ValueError, match=r"ctx\.
|
|
1396
|
+
with pytest.raises(ValueError, match=r"ctx\.fork_conversation\(\).*resume"):
|
|
1363
1397
|
await client.create_session(
|
|
1364
1398
|
profile="other-profile",
|
|
1365
1399
|
inherit_context=cast(Any, InheritedContext()),
|
|
@@ -54,6 +54,17 @@ class WeatherInput(BaseModel):
|
|
|
54
54
|
location: str
|
|
55
55
|
|
|
56
56
|
|
|
57
|
+
def test_empty_extension_registration_shape() -> None:
|
|
58
|
+
assert Extension(name="empty", version="1.0.0").initialize({}) == {
|
|
59
|
+
"name": "empty",
|
|
60
|
+
"version": "1.0.0",
|
|
61
|
+
"tools": [],
|
|
62
|
+
"commands": [],
|
|
63
|
+
"shortcuts": [],
|
|
64
|
+
"subscriptions": [],
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
57
68
|
def test_reexports_pydantic_and_jinja2() -> None:
|
|
58
69
|
class Model(Pydantic.BaseModel):
|
|
59
70
|
name: str = Pydantic.Field(min_length=1)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from kodelet_sdk import ExecutionOptions
|
|
8
|
+
from kodelet_sdk.execution import execution_args
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@pytest.mark.parametrize(
|
|
12
|
+
"options",
|
|
13
|
+
[
|
|
14
|
+
{"api_key": "secret"},
|
|
15
|
+
{"allowedTools": None},
|
|
16
|
+
{"maxTokens": 0},
|
|
17
|
+
{"max_turns": -1},
|
|
18
|
+
{"no_tools": "false"},
|
|
19
|
+
{"allowed_tools": [""]},
|
|
20
|
+
],
|
|
21
|
+
)
|
|
22
|
+
def test_execution_options_reject_unknown_null_and_invalid_values(options: dict[str, Any]) -> None:
|
|
23
|
+
with pytest.raises(ValueError):
|
|
24
|
+
ExecutionOptions.model_validate(options)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_execution_options_preserve_explicit_false_zero_and_empty_lists() -> None:
|
|
28
|
+
options = ExecutionOptions(
|
|
29
|
+
max_turns=0,
|
|
30
|
+
no_tools=False,
|
|
31
|
+
allowed_tools=[],
|
|
32
|
+
allowed_commands=[],
|
|
33
|
+
enableFSSearchTools=True,
|
|
34
|
+
)
|
|
35
|
+
assert options.to_wire() == {
|
|
36
|
+
"maxTurns": 0,
|
|
37
|
+
"noTools": False,
|
|
38
|
+
"allowedTools": [],
|
|
39
|
+
"allowedCommands": [],
|
|
40
|
+
"enableFSSearchTools": True,
|
|
41
|
+
}
|
|
42
|
+
assert execution_args(options) == [
|
|
43
|
+
"--max-turns=0",
|
|
44
|
+
"--no-tools=false",
|
|
45
|
+
"--allowed-tools=",
|
|
46
|
+
"--allowed-commands=",
|
|
47
|
+
"--enable-fs-search-tools=true",
|
|
48
|
+
]
|
|
49
|
+
assert ExecutionOptions.model_validate(options.to_wire()) == options
|
|
50
|
+
assert ExecutionOptions.model_validate({"enable_fs_search_tools": True}).to_wire() == {
|
|
51
|
+
"enableFSSearchTools": True,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_execution_options_return_independent_snapshots() -> None:
|
|
56
|
+
options = ExecutionOptions(allowed_tools=["file_read", "grep_tool", "glob_tool"])
|
|
57
|
+
snapshot = options.to_wire()
|
|
58
|
+
snapshot["allowedTools"].append("bash")
|
|
59
|
+
assert options.allowed_tools == ["file_read", "grep_tool", "glob_tool"]
|
|
60
|
+
assert ExecutionOptions().to_wire() == {}
|
|
61
|
+
assert execution_args(ExecutionOptions()) == []
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_execution_args_escape_csv_values() -> None:
|
|
65
|
+
options = ExecutionOptions(allowed_commands=['rg "a,b"', "git status"])
|
|
66
|
+
assert execution_args(options) == ['--allowed-commands="rg ""a,b""","git status"']
|