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.
Files changed (48) hide show
  1. {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/PKG-INFO +11 -42
  2. {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/README.md +10 -41
  3. kodelet_sdk-0.3.0/VERSION.txt +1 -0
  4. {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/src/kodelet_sdk/__init__.py +1 -6
  5. {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/src/kodelet_sdk/agent/client.py +4 -2
  6. {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/src/kodelet_sdk/agent/types.py +2 -1
  7. {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/src/kodelet_sdk/api.py +0 -14
  8. {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/src/kodelet_sdk/context.py +0 -3
  9. {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/src/kodelet_sdk/execution.py +1 -29
  10. {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/tests/test_agent.py +39 -5
  11. {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/tests/test_api.py +11 -0
  12. kodelet_sdk-0.3.0/tests/test_execution.py +66 -0
  13. {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/tests/test_runtime.py +30 -45
  14. kodelet_sdk-0.2.2/VERSION.txt +0 -1
  15. kodelet_sdk-0.2.2/src/kodelet_sdk/child.py +0 -226
  16. kodelet_sdk-0.2.2/tests/test_child.py +0 -498
  17. {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/.github/workflows/ci.yml +0 -0
  18. {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/.github/workflows/release.yml +0 -0
  19. {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/.gitignore +0 -0
  20. {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/AGENTS.md +0 -0
  21. {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/LICENSE +0 -0
  22. {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/Makefile +0 -0
  23. {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/examples/review/kodelet-extension-review +0 -0
  24. {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/examples/review/reviewer-prompt.md +0 -0
  25. {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/examples/sdk/README.md +0 -0
  26. {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/examples/sdk/basic-agent-session +0 -0
  27. {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/examples/sdk/inline-extension-session +0 -0
  28. {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/examples/sdk/streaming-agent-session +0 -0
  29. {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/examples/workspace/kodelet-extension-workspace +0 -0
  30. {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/pyproject.toml +0 -0
  31. {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/src/kodelet_sdk/_utils.py +0 -0
  32. {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/src/kodelet_sdk/agent/__init__.py +0 -0
  33. {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/src/kodelet_sdk/agent/relay.py +0 -0
  34. {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/src/kodelet_sdk/agent/rpc.py +0 -0
  35. {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/src/kodelet_sdk/agent/session.py +0 -0
  36. {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/src/kodelet_sdk/agent/transport.py +0 -0
  37. {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/src/kodelet_sdk/py.typed +0 -0
  38. {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/src/kodelet_sdk/runtime.py +0 -0
  39. {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/src/kodelet_sdk/schemas.py +0 -0
  40. {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/src/kodelet_sdk/task_progress.py +0 -0
  41. {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/src/kodelet_sdk/template.py +0 -0
  42. {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/src/kodelet_sdk/test_harness.py +0 -0
  43. {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/tests/fixtures/workspace_policy_extension.py +0 -0
  44. {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/tests/test_acp_transport.py +0 -0
  45. {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/tests/test_examples.py +0 -0
  46. {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/tests/test_persistent_ui.py +0 -0
  47. {kodelet_sdk-0.2.2 → kodelet_sdk-0.3.0}/tests/test_task_progress.py +0 -0
  48. {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.2.2
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
- Host calls such as `ctx.update()` and `ctx.children` 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.
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.children.start(...)` for delegated agent work.
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
- ### Child agents
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
- Start and steer calls do not retry automatically. Supply a stable `request_id` when you need to reconcile an uncertain submission; reuse it only for the same input. A disconnected start may already be running, so keep its ID and any lease until cancellation or cleanup is confirmed.
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
- For a history snapshot without execution, use `ctx.fork_conversation()`. It raises `ConversationForkUnavailableError` when unavailable. `Client.create_session(inherit_context=...)` is unsupported.
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
- Children normally end with their tool handler. To let work outlive it, acquire a lease with `await ctx.acquire_background_task(...)` and pass `lease=lease` when first starting the child inside the tool. Close the lease after the work and final updates finish.
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. Resuming after a restart requires a new authorized tool call; saved IDs alone are insufficient.
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
- Host calls such as `ctx.update()` and `ctx.children` 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.
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.children.start(...)` for delegated agent work.
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
- ### Child agents
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
- Start and steer calls do not retry automatically. Supply a stable `request_id` when you need to reconcile an uncertain submission; reuse it only for the same input. A disconnected start may already be running, so keep its ID and any lease until cancellation or cleanup is confirmed.
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
- For a history snapshot without execution, use `ctx.fork_conversation()`. It raises `ConversationForkUnavailableError` when unavailable. `Client.create_session(inherit_context=...)` is unsupported.
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
- Children normally end with their tool handler. To let work outlive it, acquire a lease with `await ctx.acquire_background_task(...)` and pass `lease=lease` when first starting the child inside the tool. Close the lease after the work and final updates finish.
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. Resuming after a restart requires a new authorized tool call; saved IDs alone are insufficient.
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, ExecutionProfile
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("Use ctx.children for scoped execution; live forking remains "
83
- "available separately through ctx.fork_conversation")
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; use ``ctx.children`` instead.
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
- """Credential-free execution options and runner-owned preset registrations."""
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 test_session_rejects_unscoped_inherited_context_before_spawn() -> None:
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 before scoped authorization")
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\.children"):
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\.children"):
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\.children"):
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"']