lisa-harness-kernel 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. kernel/__init__.py +5 -0
  2. kernel/approval.py +417 -0
  3. kernel/capability.py +153 -0
  4. kernel/capability_binding.py +75 -0
  5. kernel/capability_manifest.py +232 -0
  6. kernel/config.py +475 -0
  7. kernel/content_roots.py +265 -0
  8. kernel/context/__init__.py +34 -0
  9. kernel/context/ambient.py +89 -0
  10. kernel/context/content_registry.py +33 -0
  11. kernel/context/context_budget.py +357 -0
  12. kernel/context/context_mapper.py +926 -0
  13. kernel/context/event_bus.py +58 -0
  14. kernel/context/extension_item.py +23 -0
  15. kernel/context/input_guard.py +25 -0
  16. kernel/context/mcp_catalog.py +514 -0
  17. kernel/context/mcp_content_registry.py +76 -0
  18. kernel/context/mcp_guard.py +132 -0
  19. kernel/context/model_policy.py +44 -0
  20. kernel/context/models.py +161 -0
  21. kernel/context/output_guard.py +25 -0
  22. kernel/context/provenance.py +181 -0
  23. kernel/context/request_budget.py +152 -0
  24. kernel/context/sub_turn_contract.py +211 -0
  25. kernel/context/tests/test_evidence_freshness_regression.py +37 -0
  26. kernel/context/tests/test_pack_scoped_items.py +135 -0
  27. kernel/context/tests/test_provenance.py +143 -0
  28. kernel/delegation.py +26 -0
  29. kernel/execution_ledger.py +118 -0
  30. kernel/execution_policy.py +120 -0
  31. kernel/extension_names.py +11 -0
  32. kernel/extension_runtime.py +32 -0
  33. kernel/function_tools.py +250 -0
  34. kernel/host_command_discovery.py +63 -0
  35. kernel/host_commands.py +65 -0
  36. kernel/intake/__init__.py +28 -0
  37. kernel/intake/connector_discovery.py +70 -0
  38. kernel/intake/connectors.py +129 -0
  39. kernel/intake/contracts.py +111 -0
  40. kernel/ipsar.py +85 -0
  41. kernel/ipsar_fsm.py +2357 -0
  42. kernel/mcp_launch/__init__.py +37 -0
  43. kernel/mcp_launch/compiler.py +304 -0
  44. kernel/mcp_launch/policy.py +427 -0
  45. kernel/mcp_launch/server_manifest.py +765 -0
  46. kernel/mcp_launch/tool_filter.py +27 -0
  47. kernel/model_budgets.py +233 -0
  48. kernel/model_manifest.py +681 -0
  49. kernel/model_router_config_discovery.py +89 -0
  50. kernel/model_router_discovery.py +105 -0
  51. kernel/models.py +2569 -0
  52. kernel/operations/__init__.py +1 -0
  53. kernel/operations/declarative.py +504 -0
  54. kernel/operations/registry.py +1082 -0
  55. kernel/operations/structured_output.py +381 -0
  56. kernel/provider_contracts.py +446 -0
  57. kernel/provider_gateway_discovery.py +87 -0
  58. kernel/proxy_launch/__init__.py +20 -0
  59. kernel/proxy_launch/compiler.py +213 -0
  60. kernel/proxy_launch/manifest.py +283 -0
  61. kernel/proxy_launch/policy.py +101 -0
  62. kernel/proxy_launch/pool.py +191 -0
  63. kernel/py.typed +0 -0
  64. kernel/recovery.py +135 -0
  65. kernel/routing_vocabulary.py +372 -0
  66. kernel/sandbox_floor.py +611 -0
  67. kernel/seams/__init__.py +7 -0
  68. kernel/seams/host_adapter.py +196 -0
  69. kernel/seams/protocols.py +466 -0
  70. kernel/session_plan.py +710 -0
  71. kernel/slice.py +296 -0
  72. kernel/slice_provider_discovery.py +107 -0
  73. kernel/state_providers.py +133 -0
  74. kernel/system_segments.py +146 -0
  75. kernel/telemetry.py +28 -0
  76. kernel/testing/__init__.py +1 -0
  77. kernel/testing/endpoint_registry.py +118 -0
  78. kernel/testing/extension_harness.py +200 -0
  79. kernel/testing/fixtures/prompts/sub_turn/budget_exhausted.md +3 -0
  80. kernel/testing/fixtures/prompts/sub_turn/delegate_contract.md +8 -0
  81. kernel/testing/fixtures/prompts/sub_turn/fork_contract.md +8 -0
  82. kernel/testing/fixtures/prompts/sub_turn/midpoint_write_nudge.md +6 -0
  83. kernel/testing/gateway_stub.py +145 -0
  84. kernel/testing/import_boundaries.py +63 -0
  85. kernel/testing/routing_vocabulary.py +45 -0
  86. kernel/testing/stubs.py +22 -0
  87. kernel/token_usage.py +302 -0
  88. kernel/tools/__init__.py +1 -0
  89. kernel/tools/mcp_client.py +2422 -0
  90. kernel/tools/output_stream.py +91 -0
  91. kernel/tools/registry.py +607 -0
  92. kernel/tools/schema_translator.py +282 -0
  93. kernel/tools/synthetic.py +113 -0
  94. kernel/turn_continuation.py +118 -0
  95. kernel/turn_hooks.py +388 -0
  96. kernel/turn_phases/__init__.py +4 -0
  97. kernel/turn_phases/_child_turn.py +442 -0
  98. kernel/turn_phases/_completion_contract.py +301 -0
  99. kernel/turn_phases/_dispatch.py +870 -0
  100. kernel/turn_phases/_entry.py +251 -0
  101. kernel/turn_phases/_fork.py +496 -0
  102. kernel/turn_phases/_lifecycle.py +1040 -0
  103. kernel/turn_phases/_post_turn.py +356 -0
  104. kernel/turn_phases/_routing.py +282 -0
  105. kernel/turn_phases/_routing_hint.py +1502 -0
  106. kernel/turn_phases/_tool_schemas.py +425 -0
  107. kernel/turn_phases/_turn_state.py +906 -0
  108. kernel/turn_runner/__init__.py +10374 -0
  109. kernel/turn_runner/_defaults.py +497 -0
  110. kernel/turn_runner/_helpers.py +222 -0
  111. kernel/turn_runner/_result.py +156 -0
  112. kernel/turn_runner/_seams.py +109 -0
  113. kernel/turn_runner/_streaming.py +66 -0
  114. kernel/workflow_position.py +593 -0
  115. kernel/workflow_view.py +524 -0
  116. lisa_harness_kernel-0.1.0.dist-info/METADATA +22 -0
  117. lisa_harness_kernel-0.1.0.dist-info/RECORD +118 -0
  118. lisa_harness_kernel-0.1.0.dist-info/WHEEL +4 -0
kernel/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Lisa kernel — runtime-neutral engine for conversational AI agents.
2
+
3
+ The kernel owns all contracts, abstractions, and core built-ins. It imports
4
+ nothing from host apps or higher-layer SDK packages.
5
+ """
kernel/approval.py ADDED
@@ -0,0 +1,417 @@
1
+ """Exact-action approval contracts for catalog-backed effects.
2
+
3
+ The kernel owns digest construction and receipt validation. Hosts own durable
4
+ storage and approval UX through the :class:`ApprovalService` protocol.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import hashlib
11
+ import json
12
+ import uuid
13
+ from collections.abc import Mapping
14
+ from datetime import UTC, datetime, timedelta
15
+ from typing import Any, Literal, Protocol, runtime_checkable
16
+
17
+ from pydantic import BaseModel, ConfigDict, Field
18
+
19
+ __all__ = [
20
+ "ApprovalDecision",
21
+ "ApprovalIdentity",
22
+ "ApprovalInvocation",
23
+ "ApprovalReceipt",
24
+ "ApprovalRecord",
25
+ "ApprovalRequired",
26
+ "ApprovalService",
27
+ "ApprovalState",
28
+ "EffectKind",
29
+ "InMemoryApprovalService",
30
+ "build_approval_invocation",
31
+ "canonical_json",
32
+ "is_expired",
33
+ "render_approval_request",
34
+ ]
35
+
36
+ EffectKind = Literal["read", "write", "execute", "external_write"]
37
+ ApprovalState = Literal["pending", "approved", "denied", "consumed", "expired"]
38
+
39
+ _DEFAULT_APPROVAL_TTL = timedelta(minutes=30)
40
+
41
+
42
+ class _FrozenApprovalModel(BaseModel):
43
+ """Base contract for immutable approval values."""
44
+
45
+ model_config = ConfigDict(frozen=True)
46
+
47
+
48
+ class ApprovalIdentity(_FrozenApprovalModel):
49
+ """Identity and session boundaries that scope an approval receipt."""
50
+
51
+ workspace_id: str = "default"
52
+ user_id: str = ""
53
+ user_slug: str = "default"
54
+ surface_type: str = "personal_dm"
55
+ surface_id: str = ""
56
+ memory_scope: str = "personal"
57
+ mode_id: str = ""
58
+ session_plan_digest: str = ""
59
+
60
+
61
+ class ApprovalInvocation(_FrozenApprovalModel):
62
+ """A canonical effectful invocation that can be approved exactly once."""
63
+
64
+ request_id: str
65
+ target_kind: Literal["tool", "operation"]
66
+ target_id: str
67
+ pack_id: str = ""
68
+ effect: EffectKind
69
+ parameters: dict[str, Any] = Field(default_factory=dict)
70
+ identity: ApprovalIdentity
71
+ pack_config_fingerprint: str
72
+ session_plan_digest: str
73
+ action_digest: str
74
+ safe_preview: str
75
+ created_at: datetime
76
+ expires_at: datetime
77
+
78
+
79
+ class ApprovalReceipt(_FrozenApprovalModel):
80
+ """Evidence that one exact pending invocation was approved."""
81
+
82
+ request_id: str
83
+ action_digest: str
84
+ identity: ApprovalIdentity
85
+ approved_at: datetime
86
+ expires_at: datetime
87
+
88
+
89
+ class ApprovalDecision(_FrozenApprovalModel):
90
+ """An operator decision recorded against an approval request."""
91
+
92
+ request_id: str
93
+ state: ApprovalState
94
+ decided_at: datetime
95
+ identity: ApprovalIdentity
96
+ reason: str = ""
97
+
98
+
99
+ class ApprovalRecord(BaseModel):
100
+ """Durable state for one requested action, owned by a host service."""
101
+
102
+ invocation: ApprovalInvocation
103
+ state: ApprovalState = "pending"
104
+ receipt: ApprovalReceipt | None = None
105
+ decision: ApprovalDecision | None = None
106
+
107
+
108
+ class ApprovalUnavailable(RuntimeError):
109
+ """The receipt failed authoritative consumption; do not continue its saved batch."""
110
+
111
+
112
+ class ApprovalRequired(RuntimeError):
113
+ """Interrupt execution until the host has approved the exact action."""
114
+
115
+ def __init__(self, invocation: ApprovalInvocation) -> None:
116
+ self.invocation = invocation
117
+ super().__init__(f"Approval required: {invocation.request_id}")
118
+
119
+
120
+ @runtime_checkable
121
+ class ApprovalService(Protocol):
122
+ """Host-owned durable approval service used by execution registries."""
123
+
124
+ async def request(self, invocation: ApprovalInvocation) -> ApprovalInvocation:
125
+ """Persist a new pending invocation and return it."""
126
+
127
+ async def approve(
128
+ self,
129
+ request_id: str,
130
+ identity: ApprovalIdentity,
131
+ ) -> ApprovalReceipt | None:
132
+ """Approve a matching pending invocation, or return ``None``."""
133
+
134
+ async def deny(
135
+ self,
136
+ request_id: str,
137
+ identity: ApprovalIdentity,
138
+ *,
139
+ reason: str = "",
140
+ ) -> bool:
141
+ """Deny a matching pending invocation."""
142
+
143
+ async def consume(
144
+ self,
145
+ request_id: str,
146
+ invocation: ApprovalInvocation,
147
+ ) -> ApprovalReceipt | None:
148
+ """Atomically consume a matching approved invocation once."""
149
+
150
+ async def get(self, request_id: str) -> ApprovalRecord | None:
151
+ """Return the persisted request state without changing it."""
152
+
153
+ async def list_pending(self, identity: ApprovalIdentity) -> list[ApprovalRecord]:
154
+ """Return non-expired pending records owned by ``identity``.
155
+
156
+ Hosts use this to detect affirmative-resume conditions (e.g. the
157
+ user typing "proceed" while a paused action awaits approval) and
158
+ to surface pending approvals in UIs. The identity match is exact.
159
+ """
160
+
161
+
162
+ def _find_pending_for_action(
163
+ records: Mapping[str, ApprovalRecord],
164
+ invocation: ApprovalInvocation,
165
+ *,
166
+ now: datetime,
167
+ ) -> ApprovalRecord | None:
168
+ """Return a non-expired pending record matching action_digest + identity.
169
+
170
+ The exact-action contract approves one canonical invocation once. When
171
+ a caller re-issues the same effectful action (same target, parameters,
172
+ pack config fingerprint, and owner identity) before the user has
173
+ decided, the service must not mint a fresh ``request_id`` — that would
174
+ strand the original pending request and force the user to re-approve a
175
+ new id on every retry. Returning the existing pending record preserves
176
+ the id the user already saw so a prior or future ``/approve <id>`` still
177
+ satisfies it. Only the ``pending`` state is idempotent; once approved or
178
+ consumed, a genuinely new request mints a new id.
179
+ """
180
+
181
+ for record in records.values():
182
+ if record.state != "pending":
183
+ continue
184
+ if is_expired(record.invocation, now=now):
185
+ continue
186
+ if record.invocation.action_digest == invocation.action_digest and _identity_matches(
187
+ record.invocation.identity, invocation.identity
188
+ ):
189
+ return record
190
+ return None
191
+
192
+
193
+ def _canonicalize(value: Any) -> Any:
194
+ """Normalize supported JSON-like values into stable, safe digest input."""
195
+
196
+ if isinstance(value, BaseModel):
197
+ return _canonicalize(value.model_dump(mode="json"))
198
+ if isinstance(value, Mapping):
199
+ return {str(key): _canonicalize(item) for key, item in sorted(value.items())}
200
+ if isinstance(value, list | tuple):
201
+ return [_canonicalize(item) for item in value]
202
+ if isinstance(value, set | frozenset):
203
+ normalized = [_canonicalize(item) for item in value]
204
+ return sorted(normalized, key=lambda item: json.dumps(item, sort_keys=True))
205
+ return _canonicalize_scalar(value)
206
+
207
+
208
+ def _canonicalize_scalar(value: Any) -> Any:
209
+ """Normalize a scalar leaf (datetime / None / str / int / float / bool)."""
210
+ if isinstance(value, datetime):
211
+ return value.astimezone(UTC).isoformat()
212
+ if value is None or isinstance(value, str | int | float | bool):
213
+ return value
214
+ return str(value)
215
+
216
+
217
+ def canonical_json(value: Any) -> str:
218
+ """Return a deterministic JSON representation suitable for SHA-256 input."""
219
+
220
+ return json.dumps(
221
+ _canonicalize(value),
222
+ ensure_ascii=True,
223
+ sort_keys=True,
224
+ separators=(",", ":"),
225
+ )
226
+
227
+
228
+ def _digest(value: Any) -> str:
229
+ return hashlib.sha256(canonical_json(value).encode("utf-8")).hexdigest()
230
+
231
+
232
+ def build_approval_invocation(
233
+ *,
234
+ target_kind: Literal["tool", "operation"],
235
+ target_id: str,
236
+ pack_id: str,
237
+ effect: EffectKind,
238
+ parameters: Mapping[str, Any],
239
+ identity: ApprovalIdentity,
240
+ pack_config: Mapping[str, Any],
241
+ safe_preview: str = "",
242
+ now: datetime | None = None,
243
+ ttl: timedelta = _DEFAULT_APPROVAL_TTL,
244
+ ) -> ApprovalInvocation:
245
+ """Build an exact-action request from canonical validated inputs."""
246
+
247
+ created_at = now or datetime.now(UTC)
248
+ normalized_parameters = _canonicalize(parameters)
249
+ if not isinstance(normalized_parameters, dict):
250
+ normalized_parameters = {}
251
+ config_fingerprint = _digest(pack_config)
252
+ action_payload = {
253
+ "target_kind": target_kind,
254
+ "target_id": target_id,
255
+ "pack_id": pack_id,
256
+ "effect": effect,
257
+ "parameters": normalized_parameters,
258
+ "identity": identity,
259
+ "pack_config_fingerprint": config_fingerprint,
260
+ "session_plan_digest": identity.session_plan_digest,
261
+ }
262
+ return ApprovalInvocation(
263
+ request_id=uuid.uuid4().hex,
264
+ target_kind=target_kind,
265
+ target_id=target_id,
266
+ pack_id=pack_id,
267
+ effect=effect,
268
+ parameters=normalized_parameters,
269
+ identity=identity,
270
+ pack_config_fingerprint=config_fingerprint,
271
+ session_plan_digest=identity.session_plan_digest,
272
+ action_digest=_digest(action_payload),
273
+ safe_preview=safe_preview.strip() or f"{effect.replace('_', ' ')} {target_id}",
274
+ created_at=created_at,
275
+ expires_at=created_at + ttl,
276
+ )
277
+
278
+
279
+ def is_expired(invocation: ApprovalInvocation, *, now: datetime | None = None) -> bool:
280
+ """Return whether an approval request has passed its expiry time."""
281
+
282
+ return (now or datetime.now(UTC)) >= invocation.expires_at
283
+
284
+
285
+ def render_approval_request(invocation: ApprovalInvocation) -> str:
286
+ """Render the host-neutral user prompt for a paused action.
287
+
288
+ The kernel states the situation without command syntax: which action is
289
+ paused and that an operator decision is pending. Hosts own the *how* —
290
+ the runtime builds the structured ``data["approval"]`` payload (with
291
+ ``request_id``) for panel transports and appends the installed command
292
+ hints to the text for plain transports.
293
+ """
294
+
295
+ return (
296
+ f"Approval required for {invocation.safe_preview}.\n\n"
297
+ f"This action is paused until you approve or deny it "
298
+ f"(request {invocation.request_id})."
299
+ )
300
+
301
+
302
+ def _identity_matches(expected: ApprovalIdentity, actual: ApprovalIdentity) -> bool:
303
+ return expected == actual
304
+
305
+
306
+ class InMemoryApprovalService:
307
+ """Lock-protected approval service used by kernel tests and lightweight hosts."""
308
+
309
+ def __init__(self) -> None:
310
+ self._records: dict[str, ApprovalRecord] = {}
311
+ self._lock = asyncio.Lock()
312
+
313
+ async def request(self, invocation: ApprovalInvocation) -> ApprovalInvocation:
314
+ async with self._lock:
315
+ existing = _find_pending_for_action(self._records, invocation, now=datetime.now(UTC))
316
+ if existing is not None:
317
+ return existing.invocation
318
+ self._records.setdefault(invocation.request_id, ApprovalRecord(invocation=invocation))
319
+ return self._records[invocation.request_id].invocation
320
+
321
+ async def approve(
322
+ self,
323
+ request_id: str,
324
+ identity: ApprovalIdentity,
325
+ ) -> ApprovalReceipt | None:
326
+ async with self._lock:
327
+ record = self._records.get(request_id)
328
+ if record is None or not _identity_matches(record.invocation.identity, identity):
329
+ return None
330
+ if is_expired(record.invocation):
331
+ record.state = "expired"
332
+ return None
333
+ if record.state != "pending":
334
+ return None
335
+ receipt = ApprovalReceipt(
336
+ request_id=request_id,
337
+ action_digest=record.invocation.action_digest,
338
+ identity=identity,
339
+ approved_at=datetime.now(UTC),
340
+ expires_at=record.invocation.expires_at,
341
+ )
342
+ record.state = "approved"
343
+ record.receipt = receipt
344
+ record.decision = ApprovalDecision(
345
+ request_id=request_id,
346
+ state="approved",
347
+ decided_at=receipt.approved_at,
348
+ identity=identity,
349
+ )
350
+ return receipt
351
+
352
+ async def deny(
353
+ self,
354
+ request_id: str,
355
+ identity: ApprovalIdentity,
356
+ *,
357
+ reason: str = "",
358
+ ) -> bool:
359
+ async with self._lock:
360
+ record = self._records.get(request_id)
361
+ if record is None or not _identity_matches(record.invocation.identity, identity):
362
+ return False
363
+ if record.state != "pending":
364
+ return False
365
+ record.state = "denied"
366
+ record.decision = ApprovalDecision(
367
+ request_id=request_id,
368
+ state="denied",
369
+ decided_at=datetime.now(UTC),
370
+ identity=identity,
371
+ reason=reason.strip(),
372
+ )
373
+ return True
374
+
375
+ async def consume(
376
+ self,
377
+ request_id: str,
378
+ invocation: ApprovalInvocation,
379
+ ) -> ApprovalReceipt | None:
380
+ async with self._lock:
381
+ record = self._records.get(request_id)
382
+ if record is None:
383
+ return None
384
+ if is_expired(record.invocation):
385
+ record.state = "expired"
386
+ return None
387
+ if (
388
+ record.state != "approved"
389
+ or record.receipt is None
390
+ or record.receipt.action_digest != invocation.action_digest
391
+ or not _identity_matches(record.invocation.identity, invocation.identity)
392
+ ):
393
+ return None
394
+ receipt = record.receipt
395
+ record.state = "consumed"
396
+ record.decision = ApprovalDecision(
397
+ request_id=request_id,
398
+ state="consumed",
399
+ decided_at=datetime.now(UTC),
400
+ identity=invocation.identity,
401
+ )
402
+ return receipt
403
+
404
+ async def get(self, request_id: str) -> ApprovalRecord | None:
405
+ async with self._lock:
406
+ return self._records.get(request_id)
407
+
408
+ async def list_pending(self, identity: ApprovalIdentity) -> list[ApprovalRecord]:
409
+ async with self._lock:
410
+ now = datetime.now(UTC)
411
+ return [
412
+ record
413
+ for record in self._records.values()
414
+ if record.state == "pending"
415
+ and not is_expired(record.invocation, now=now)
416
+ and _identity_matches(record.invocation.identity, identity)
417
+ ]
kernel/capability.py ADDED
@@ -0,0 +1,153 @@
1
+ """Single resolution layer for capability ids across operation and tool catalogs.
2
+
3
+ The kernel resolves a capability id (``operation_id`` or ``tool_id``) against
4
+ two in-process catalogs: ``get_operation_catalog()`` and ``get_tool_catalog()``.
5
+ Before this module existed, the resolution pattern
6
+ ``operation_catalog.get(x) or tool_catalog.get(x)`` was copy-pasted at every
7
+ call site (``ipsar_fsm``, ``turn_runner``, the operation registry's schema
8
+ builder). That made resolution-order changes fragile and left no single place
9
+ to add cross-cutting concerns (telemetry, normalized keying, audit).
10
+
11
+ This module owns the canonical resolution contract:
12
+
13
+ * ``resolve_capability(id)`` returns the first matching definition, with
14
+ operations taking precedence over tools on id collision. This matches the
15
+ order every existing call site used, so behavior is unchanged.
16
+ * ``resolve_operation_definition`` / ``resolve_tool_definition`` are the
17
+ per-catalog entry points for callers that know which catalog they want.
18
+ * ``resolve_capability_for_turn_limit`` is the dedicated entry point for
19
+ per-turn ``max_calls_per_turn`` enforcement, which previously inlined the
20
+ dual-catalog fallback with a ``getattr(..., "max_calls_per_turn", 0)``
21
+ tail at four sites.
22
+
23
+ Internal pack tools are keyed **bare** in the tool catalog
24
+ (e.g. ``web_search``). MCP-native tools are keyed **namespaced**
25
+ (``server_id.tool``, e.g. ``lisa-storage.read_model_manifest``); the MCP
26
+ client pool owns that namespace bridging separately (see
27
+ ``kernel.tools.mcp_client``). This module resolves against the kernel
28
+ catalog only — the MCP pool's namespaced index is not consulted here, by
29
+ design, so resolution never spawns a subprocess.
30
+
31
+ Called by: kernel.ipsar_fsm (lifecycle target dispatch)
32
+ kernel.turn_runner (tool-call dispatch, per-turn cap enforcement)
33
+ kernel.operations.registry (schema building)
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ from kernel.models import OperationDefinition, ToolDefinition
39
+
40
+ __all__ = [
41
+ "CapabilityDefinition",
42
+ "resolve_capability",
43
+ "resolve_capability_for_turn_limit",
44
+ "resolve_operation_definition",
45
+ "resolve_tool_definition",
46
+ ]
47
+
48
+ CapabilityDefinition = OperationDefinition | ToolDefinition
49
+
50
+
51
+ def resolve_operation_definition(
52
+ capability_id: str,
53
+ *,
54
+ operation_catalog: dict[str, OperationDefinition] | None = None,
55
+ ) -> OperationDefinition | None:
56
+ """Look up one operation id in the operation catalog.
57
+
58
+ Args:
59
+ capability_id: The bare ``operation_id`` to resolve.
60
+ operation_catalog: Optional pre-resolved catalog. When omitted, the
61
+ kernel's ``get_operation_catalog()`` is used.
62
+
63
+ Returns:
64
+ The matching ``OperationDefinition`` or ``None``.
65
+ """
66
+ catalog = operation_catalog if operation_catalog is not None else _default_operation_catalog()
67
+ return catalog.get(capability_id)
68
+
69
+
70
+ def resolve_tool_definition(
71
+ capability_id: str,
72
+ *,
73
+ tool_catalog: dict[str, ToolDefinition] | None = None,
74
+ ) -> ToolDefinition | None:
75
+ """Look up one tool id in the tool catalog.
76
+
77
+ Args:
78
+ capability_id: The tool id to resolve. Internal pack tools are keyed
79
+ bare (e.g. ``web_search``); MCP-native tools are keyed namespaced
80
+ (e.g. ``lisa-storage.read_model_manifest``) but are owned by the
81
+ MCP client pool, not the kernel tool catalog.
82
+ tool_catalog: Optional pre-resolved catalog. When omitted, the
83
+ kernel's ``get_tool_catalog()`` is used.
84
+
85
+ Returns:
86
+ The matching ``ToolDefinition`` or ``None``.
87
+ """
88
+ catalog = tool_catalog if tool_catalog is not None else _default_tool_catalog()
89
+ return catalog.get(capability_id)
90
+
91
+
92
+ def resolve_capability(
93
+ capability_id: str,
94
+ *,
95
+ operation_catalog: dict[str, OperationDefinition] | None = None,
96
+ tool_catalog: dict[str, ToolDefinition] | None = None,
97
+ ) -> CapabilityDefinition | None:
98
+ """Resolve a capability id against operations first, then tools.
99
+
100
+ On id collision, operations take precedence over tools. This matches the
101
+ order every existing inlined call site used
102
+ (``operation_catalog.get(x) or tool_catalog.get(x)``), so behavior is
103
+ unchanged. Callers that need a specific catalog should use
104
+ ``resolve_operation_definition`` or ``resolve_tool_definition`` instead.
105
+
106
+ Args:
107
+ capability_id: The ``operation_id`` or ``tool_id`` to resolve.
108
+ operation_catalog: Optional pre-resolved operation catalog.
109
+ tool_catalog: Optional pre-resolved tool catalog.
110
+
111
+ Returns:
112
+ The matching ``OperationDefinition`` or ``ToolDefinition``, or
113
+ ``None`` when the id is unknown to both catalogs.
114
+ """
115
+ op = resolve_operation_definition(capability_id, operation_catalog=operation_catalog)
116
+ if op is not None:
117
+ return op
118
+ return resolve_tool_definition(capability_id, tool_catalog=tool_catalog)
119
+
120
+
121
+ def resolve_capability_for_turn_limit(
122
+ capability_id: str,
123
+ *,
124
+ operation_catalog: dict[str, OperationDefinition] | None = None,
125
+ tool_catalog: dict[str, ToolDefinition] | None = None,
126
+ ) -> CapabilityDefinition | None:
127
+ """Resolve a capability id for per-turn ``max_calls_per_turn`` enforcement.
128
+
129
+ Same precedence as ``resolve_capability`` (operations before tools). Both
130
+ definition types expose ``max_calls_per_turn``; callers apply
131
+ ``getattr(definition, "max_calls_per_turn", 0)`` on the result. MCP-native
132
+ tools have no kernel ``ToolDefinition`` and are bounded only by the global
133
+ iteration count, so they correctly resolve to ``None`` here.
134
+ """
135
+ return resolve_capability(
136
+ capability_id,
137
+ operation_catalog=operation_catalog,
138
+ tool_catalog=tool_catalog,
139
+ )
140
+
141
+
142
+ def _default_operation_catalog() -> dict[str, OperationDefinition]:
143
+ """Return the kernel operation catalog, importing lazily to avoid cycles."""
144
+ from kernel.operations.registry import get_operation_catalog
145
+
146
+ return get_operation_catalog()
147
+
148
+
149
+ def _default_tool_catalog() -> dict[str, ToolDefinition]:
150
+ """Return the kernel tool catalog, importing lazily to avoid cycles."""
151
+ from kernel.tools.registry import get_tool_catalog
152
+
153
+ return get_tool_catalog()
@@ -0,0 +1,75 @@
1
+ """Execution-time bindings for catalog capabilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from dataclasses import dataclass, field
7
+ from types import MappingProxyType
8
+ from typing import Any, Literal, Protocol, runtime_checkable
9
+
10
+ CapabilityKind = Literal["operation", "tool"]
11
+ OwnerId = str
12
+
13
+
14
+ @dataclass(frozen=True, slots=True)
15
+ class CapabilityRef:
16
+ """Stable identity of one catalog capability."""
17
+
18
+ kind: CapabilityKind
19
+ capability_id: str
20
+ owner_id: OwnerId
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class CapabilityBinding:
25
+ """Immutable runtime configuration bound to one catalog capability."""
26
+
27
+ owner_id: OwnerId
28
+ configuration: Mapping[str, Any]
29
+
30
+ def __post_init__(self) -> None:
31
+ object.__setattr__(self, "configuration", _freeze(self.configuration))
32
+
33
+
34
+ @dataclass(frozen=True, slots=True)
35
+ class ExecutionFloorView:
36
+ """Non-secret execution posture exposed to a composing capability."""
37
+
38
+ execution: str | None = None
39
+ persistence: str | None = None
40
+ paths: Mapping[str, str] = field(default_factory=lambda: MappingProxyType({}))
41
+ # Sandbox postures the composing capability must re-request explicitly
42
+ # (bwrap's policy join treats an unspecified request axis as the pack's
43
+ # restrictive default, so a floor allowance like ``network: allow`` only
44
+ # takes effect when the caller forwards it).
45
+ network: str | None = None
46
+ device: str | None = None
47
+ ipc: str | None = None
48
+
49
+
50
+ @runtime_checkable
51
+ class CapabilityBindingProvider(Protocol):
52
+ """Resolve one concrete catalog capability for the active session."""
53
+
54
+ def binding_for(self, capability: CapabilityRef) -> CapabilityBinding | None:
55
+ """Return the binding, or ``None`` when the capability is not active."""
56
+
57
+
58
+ def _freeze(value: Any) -> Any:
59
+ if isinstance(value, Mapping):
60
+ return MappingProxyType({str(key): _freeze(item) for key, item in value.items()})
61
+ if isinstance(value, list | tuple):
62
+ return tuple(_freeze(item) for item in value)
63
+ if isinstance(value, set | frozenset):
64
+ return frozenset(_freeze(item) for item in value)
65
+ return value
66
+
67
+
68
+ __all__ = [
69
+ "CapabilityBinding",
70
+ "CapabilityBindingProvider",
71
+ "CapabilityKind",
72
+ "CapabilityRef",
73
+ "ExecutionFloorView",
74
+ "OwnerId",
75
+ ]