lisa-harness-kernel 0.1.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.
- lisa_harness_kernel-0.1.0/.gitignore +33 -0
- lisa_harness_kernel-0.1.0/.instructions.md +12 -0
- lisa_harness_kernel-0.1.0/PKG-INFO +22 -0
- lisa_harness_kernel-0.1.0/README.md +137 -0
- lisa_harness_kernel-0.1.0/pyproject.toml +47 -0
- lisa_harness_kernel-0.1.0/src/kernel/__init__.py +5 -0
- lisa_harness_kernel-0.1.0/src/kernel/approval.py +417 -0
- lisa_harness_kernel-0.1.0/src/kernel/capability.py +153 -0
- lisa_harness_kernel-0.1.0/src/kernel/capability_binding.py +75 -0
- lisa_harness_kernel-0.1.0/src/kernel/capability_manifest.py +232 -0
- lisa_harness_kernel-0.1.0/src/kernel/config.py +475 -0
- lisa_harness_kernel-0.1.0/src/kernel/content_roots.py +265 -0
- lisa_harness_kernel-0.1.0/src/kernel/context/__init__.py +34 -0
- lisa_harness_kernel-0.1.0/src/kernel/context/ambient.py +89 -0
- lisa_harness_kernel-0.1.0/src/kernel/context/content_registry.py +33 -0
- lisa_harness_kernel-0.1.0/src/kernel/context/context_budget.py +357 -0
- lisa_harness_kernel-0.1.0/src/kernel/context/context_mapper.py +926 -0
- lisa_harness_kernel-0.1.0/src/kernel/context/event_bus.py +58 -0
- lisa_harness_kernel-0.1.0/src/kernel/context/extension_item.py +23 -0
- lisa_harness_kernel-0.1.0/src/kernel/context/input_guard.py +25 -0
- lisa_harness_kernel-0.1.0/src/kernel/context/mcp_catalog.py +514 -0
- lisa_harness_kernel-0.1.0/src/kernel/context/mcp_content_registry.py +76 -0
- lisa_harness_kernel-0.1.0/src/kernel/context/mcp_guard.py +132 -0
- lisa_harness_kernel-0.1.0/src/kernel/context/model_policy.py +44 -0
- lisa_harness_kernel-0.1.0/src/kernel/context/models.py +161 -0
- lisa_harness_kernel-0.1.0/src/kernel/context/output_guard.py +25 -0
- lisa_harness_kernel-0.1.0/src/kernel/context/provenance.py +181 -0
- lisa_harness_kernel-0.1.0/src/kernel/context/request_budget.py +152 -0
- lisa_harness_kernel-0.1.0/src/kernel/context/sub_turn_contract.py +211 -0
- lisa_harness_kernel-0.1.0/src/kernel/context/tests/test_evidence_freshness_regression.py +37 -0
- lisa_harness_kernel-0.1.0/src/kernel/context/tests/test_pack_scoped_items.py +135 -0
- lisa_harness_kernel-0.1.0/src/kernel/context/tests/test_provenance.py +143 -0
- lisa_harness_kernel-0.1.0/src/kernel/delegation.py +26 -0
- lisa_harness_kernel-0.1.0/src/kernel/execution_ledger.py +118 -0
- lisa_harness_kernel-0.1.0/src/kernel/execution_policy.py +120 -0
- lisa_harness_kernel-0.1.0/src/kernel/extension_names.py +11 -0
- lisa_harness_kernel-0.1.0/src/kernel/extension_runtime.py +32 -0
- lisa_harness_kernel-0.1.0/src/kernel/function_tools.py +250 -0
- lisa_harness_kernel-0.1.0/src/kernel/host_command_discovery.py +63 -0
- lisa_harness_kernel-0.1.0/src/kernel/host_commands.py +65 -0
- lisa_harness_kernel-0.1.0/src/kernel/intake/__init__.py +28 -0
- lisa_harness_kernel-0.1.0/src/kernel/intake/connector_discovery.py +70 -0
- lisa_harness_kernel-0.1.0/src/kernel/intake/connectors.py +129 -0
- lisa_harness_kernel-0.1.0/src/kernel/intake/contracts.py +111 -0
- lisa_harness_kernel-0.1.0/src/kernel/ipsar.py +85 -0
- lisa_harness_kernel-0.1.0/src/kernel/ipsar_fsm.py +2357 -0
- lisa_harness_kernel-0.1.0/src/kernel/mcp_launch/__init__.py +37 -0
- lisa_harness_kernel-0.1.0/src/kernel/mcp_launch/compiler.py +304 -0
- lisa_harness_kernel-0.1.0/src/kernel/mcp_launch/policy.py +427 -0
- lisa_harness_kernel-0.1.0/src/kernel/mcp_launch/server_manifest.py +765 -0
- lisa_harness_kernel-0.1.0/src/kernel/mcp_launch/tool_filter.py +27 -0
- lisa_harness_kernel-0.1.0/src/kernel/model_budgets.py +233 -0
- lisa_harness_kernel-0.1.0/src/kernel/model_manifest.py +681 -0
- lisa_harness_kernel-0.1.0/src/kernel/model_router_config_discovery.py +89 -0
- lisa_harness_kernel-0.1.0/src/kernel/model_router_discovery.py +105 -0
- lisa_harness_kernel-0.1.0/src/kernel/models.py +2569 -0
- lisa_harness_kernel-0.1.0/src/kernel/operations/__init__.py +1 -0
- lisa_harness_kernel-0.1.0/src/kernel/operations/declarative.py +504 -0
- lisa_harness_kernel-0.1.0/src/kernel/operations/registry.py +1082 -0
- lisa_harness_kernel-0.1.0/src/kernel/operations/structured_output.py +381 -0
- lisa_harness_kernel-0.1.0/src/kernel/provider_contracts.py +446 -0
- lisa_harness_kernel-0.1.0/src/kernel/provider_gateway_discovery.py +87 -0
- lisa_harness_kernel-0.1.0/src/kernel/proxy_launch/__init__.py +20 -0
- lisa_harness_kernel-0.1.0/src/kernel/proxy_launch/compiler.py +213 -0
- lisa_harness_kernel-0.1.0/src/kernel/proxy_launch/manifest.py +283 -0
- lisa_harness_kernel-0.1.0/src/kernel/proxy_launch/policy.py +101 -0
- lisa_harness_kernel-0.1.0/src/kernel/proxy_launch/pool.py +191 -0
- lisa_harness_kernel-0.1.0/src/kernel/py.typed +0 -0
- lisa_harness_kernel-0.1.0/src/kernel/recovery.py +135 -0
- lisa_harness_kernel-0.1.0/src/kernel/routing_vocabulary.py +372 -0
- lisa_harness_kernel-0.1.0/src/kernel/sandbox_floor.py +611 -0
- lisa_harness_kernel-0.1.0/src/kernel/seams/__init__.py +7 -0
- lisa_harness_kernel-0.1.0/src/kernel/seams/host_adapter.py +196 -0
- lisa_harness_kernel-0.1.0/src/kernel/seams/protocols.py +466 -0
- lisa_harness_kernel-0.1.0/src/kernel/session_plan.py +710 -0
- lisa_harness_kernel-0.1.0/src/kernel/slice.py +296 -0
- lisa_harness_kernel-0.1.0/src/kernel/slice_provider_discovery.py +107 -0
- lisa_harness_kernel-0.1.0/src/kernel/state_providers.py +133 -0
- lisa_harness_kernel-0.1.0/src/kernel/system_segments.py +146 -0
- lisa_harness_kernel-0.1.0/src/kernel/telemetry.py +28 -0
- lisa_harness_kernel-0.1.0/src/kernel/testing/__init__.py +1 -0
- lisa_harness_kernel-0.1.0/src/kernel/testing/endpoint_registry.py +118 -0
- lisa_harness_kernel-0.1.0/src/kernel/testing/extension_harness.py +200 -0
- lisa_harness_kernel-0.1.0/src/kernel/testing/fixtures/prompts/sub_turn/budget_exhausted.md +3 -0
- lisa_harness_kernel-0.1.0/src/kernel/testing/fixtures/prompts/sub_turn/delegate_contract.md +8 -0
- lisa_harness_kernel-0.1.0/src/kernel/testing/fixtures/prompts/sub_turn/fork_contract.md +8 -0
- lisa_harness_kernel-0.1.0/src/kernel/testing/fixtures/prompts/sub_turn/midpoint_write_nudge.md +6 -0
- lisa_harness_kernel-0.1.0/src/kernel/testing/gateway_stub.py +145 -0
- lisa_harness_kernel-0.1.0/src/kernel/testing/import_boundaries.py +63 -0
- lisa_harness_kernel-0.1.0/src/kernel/testing/routing_vocabulary.py +45 -0
- lisa_harness_kernel-0.1.0/src/kernel/testing/stubs.py +22 -0
- lisa_harness_kernel-0.1.0/src/kernel/token_usage.py +302 -0
- lisa_harness_kernel-0.1.0/src/kernel/tools/__init__.py +1 -0
- lisa_harness_kernel-0.1.0/src/kernel/tools/mcp_client.py +2422 -0
- lisa_harness_kernel-0.1.0/src/kernel/tools/output_stream.py +91 -0
- lisa_harness_kernel-0.1.0/src/kernel/tools/registry.py +607 -0
- lisa_harness_kernel-0.1.0/src/kernel/tools/schema_translator.py +282 -0
- lisa_harness_kernel-0.1.0/src/kernel/tools/synthetic.py +113 -0
- lisa_harness_kernel-0.1.0/src/kernel/turn_continuation.py +118 -0
- lisa_harness_kernel-0.1.0/src/kernel/turn_hooks.py +388 -0
- lisa_harness_kernel-0.1.0/src/kernel/turn_phases/__init__.py +4 -0
- lisa_harness_kernel-0.1.0/src/kernel/turn_phases/_child_turn.py +442 -0
- lisa_harness_kernel-0.1.0/src/kernel/turn_phases/_completion_contract.py +301 -0
- lisa_harness_kernel-0.1.0/src/kernel/turn_phases/_dispatch.py +870 -0
- lisa_harness_kernel-0.1.0/src/kernel/turn_phases/_entry.py +251 -0
- lisa_harness_kernel-0.1.0/src/kernel/turn_phases/_fork.py +496 -0
- lisa_harness_kernel-0.1.0/src/kernel/turn_phases/_lifecycle.py +1040 -0
- lisa_harness_kernel-0.1.0/src/kernel/turn_phases/_post_turn.py +356 -0
- lisa_harness_kernel-0.1.0/src/kernel/turn_phases/_routing.py +282 -0
- lisa_harness_kernel-0.1.0/src/kernel/turn_phases/_routing_hint.py +1502 -0
- lisa_harness_kernel-0.1.0/src/kernel/turn_phases/_tool_schemas.py +425 -0
- lisa_harness_kernel-0.1.0/src/kernel/turn_phases/_turn_state.py +906 -0
- lisa_harness_kernel-0.1.0/src/kernel/turn_runner/__init__.py +10374 -0
- lisa_harness_kernel-0.1.0/src/kernel/turn_runner/_defaults.py +497 -0
- lisa_harness_kernel-0.1.0/src/kernel/turn_runner/_helpers.py +222 -0
- lisa_harness_kernel-0.1.0/src/kernel/turn_runner/_result.py +156 -0
- lisa_harness_kernel-0.1.0/src/kernel/turn_runner/_seams.py +109 -0
- lisa_harness_kernel-0.1.0/src/kernel/turn_runner/_streaming.py +66 -0
- lisa_harness_kernel-0.1.0/src/kernel/workflow_position.py +593 -0
- lisa_harness_kernel-0.1.0/src/kernel/workflow_view.py +524 -0
- lisa_harness_kernel-0.1.0/tests/conftest.py +336 -0
- lisa_harness_kernel-0.1.0/tests/conftest_catalog.py +139 -0
- lisa_harness_kernel-0.1.0/tests/test_approval.py +401 -0
- lisa_harness_kernel-0.1.0/tests/test_budget_completion_rail.py +124 -0
- lisa_harness_kernel-0.1.0/tests/test_capability_manifest.py +171 -0
- lisa_harness_kernel-0.1.0/tests/test_capability_resolution.py +318 -0
- lisa_harness_kernel-0.1.0/tests/test_catalog_loader.py +1013 -0
- lisa_harness_kernel-0.1.0/tests/test_child_turn_approval.py +43 -0
- lisa_harness_kernel-0.1.0/tests/test_completion_contract.py +257 -0
- lisa_harness_kernel-0.1.0/tests/test_config_env_prefix.py +87 -0
- lisa_harness_kernel-0.1.0/tests/test_connector_discovery.py +126 -0
- lisa_harness_kernel-0.1.0/tests/test_content_roots.py +172 -0
- lisa_harness_kernel-0.1.0/tests/test_context_mapper.py +582 -0
- lisa_harness_kernel-0.1.0/tests/test_control_channel.py +446 -0
- lisa_harness_kernel-0.1.0/tests/test_effect_receipts.py +2407 -0
- lisa_harness_kernel-0.1.0/tests/test_endpoint_registry.py +156 -0
- lisa_harness_kernel-0.1.0/tests/test_execution_ledger.py +71 -0
- lisa_harness_kernel-0.1.0/tests/test_execution_policy.py +42 -0
- lisa_harness_kernel-0.1.0/tests/test_extension_harness.py +94 -0
- lisa_harness_kernel-0.1.0/tests/test_guardrails.py +213 -0
- lisa_harness_kernel-0.1.0/tests/test_handoff_user_context_refresh.py +197 -0
- lisa_harness_kernel-0.1.0/tests/test_import_boundaries.py +57 -0
- lisa_harness_kernel-0.1.0/tests/test_lifecycle_resolver.py +911 -0
- lisa_harness_kernel-0.1.0/tests/test_mcp_adapters.py +232 -0
- lisa_harness_kernel-0.1.0/tests/test_mcp_bare_name_precompute.py +232 -0
- lisa_harness_kernel-0.1.0/tests/test_mcp_catalog.py +82 -0
- lisa_harness_kernel-0.1.0/tests/test_mcp_in_process.py +144 -0
- lisa_harness_kernel-0.1.0/tests/test_mcp_launch.py +648 -0
- lisa_harness_kernel-0.1.0/tests/test_mcp_warm_pool.py +266 -0
- lisa_harness_kernel-0.1.0/tests/test_mode_instructions.py +136 -0
- lisa_harness_kernel-0.1.0/tests/test_model_budgets.py +274 -0
- lisa_harness_kernel-0.1.0/tests/test_model_router.py +214 -0
- lisa_harness_kernel-0.1.0/tests/test_model_router_config_discovery.py +120 -0
- lisa_harness_kernel-0.1.0/tests/test_openai_function_tools.py +143 -0
- lisa_harness_kernel-0.1.0/tests/test_operation_model_router.py +137 -0
- lisa_harness_kernel-0.1.0/tests/test_operation_result_status.py +81 -0
- lisa_harness_kernel-0.1.0/tests/test_operation_schemaless_advertisement.py +159 -0
- lisa_harness_kernel-0.1.0/tests/test_operations_declarative.py +332 -0
- lisa_harness_kernel-0.1.0/tests/test_operations_structured_output.py +246 -0
- lisa_harness_kernel-0.1.0/tests/test_pack_configuration.py +208 -0
- lisa_harness_kernel-0.1.0/tests/test_per_step_budget.py +424 -0
- lisa_harness_kernel-0.1.0/tests/test_prompt_cache_key.py +23 -0
- lisa_harness_kernel-0.1.0/tests/test_provider_continuation.py +111 -0
- lisa_harness_kernel-0.1.0/tests/test_proxy_launch.py +487 -0
- lisa_harness_kernel-0.1.0/tests/test_proxy_policy.py +546 -0
- lisa_harness_kernel-0.1.0/tests/test_ratio_cap.py +214 -0
- lisa_harness_kernel-0.1.0/tests/test_read_loop_budget_nudge.py +44 -0
- lisa_harness_kernel-0.1.0/tests/test_request_context_budget.py +207 -0
- lisa_harness_kernel-0.1.0/tests/test_routing_vocabulary.py +214 -0
- lisa_harness_kernel-0.1.0/tests/test_sandbox_floor.py +336 -0
- lisa_harness_kernel-0.1.0/tests/test_server_manifest.py +816 -0
- lisa_harness_kernel-0.1.0/tests/test_session_plan.py +418 -0
- lisa_harness_kernel-0.1.0/tests/test_slice_provider_discovery.py +91 -0
- lisa_harness_kernel-0.1.0/tests/test_streaming.py +1296 -0
- lisa_harness_kernel-0.1.0/tests/test_synthetic_result_map.py +144 -0
- lisa_harness_kernel-0.1.0/tests/test_synthetic_tools.py +638 -0
- lisa_harness_kernel-0.1.0/tests/test_system_segments.py +108 -0
- lisa_harness_kernel-0.1.0/tests/test_token_usage.py +229 -0
- lisa_harness_kernel-0.1.0/tests/test_token_usage_publish.py +116 -0
- lisa_harness_kernel-0.1.0/tests/test_tool_call_escape_repair.py +208 -0
- lisa_harness_kernel-0.1.0/tests/test_tool_context_budget.py +23 -0
- lisa_harness_kernel-0.1.0/tests/test_tool_output_stream.py +193 -0
- lisa_harness_kernel-0.1.0/tests/test_tool_wrapper_timeout.py +473 -0
- lisa_harness_kernel-0.1.0/tests/test_truncated_tool_arguments.py +121 -0
- lisa_harness_kernel-0.1.0/tests/test_turn_completion_budget.py +281 -0
- lisa_harness_kernel-0.1.0/tests/test_turn_continuation.py +343 -0
- lisa_harness_kernel-0.1.0/tests/test_turn_decision.py +184 -0
- lisa_harness_kernel-0.1.0/tests/test_turn_runner.py +7731 -0
- lisa_harness_kernel-0.1.0/tests/test_turn_runner_approval.py +807 -0
- lisa_harness_kernel-0.1.0/tests/test_turn_runner_approval_handoff.py +259 -0
- lisa_harness_kernel-0.1.0/tests/test_turn_runner_guards_context.py +518 -0
- lisa_harness_kernel-0.1.0/tests/test_turn_runner_operations.py +623 -0
- lisa_harness_kernel-0.1.0/tests/test_turn_runner_prompt_segments.py +244 -0
- lisa_harness_kernel-0.1.0/tests/test_turn_runner_schema_builder.py +855 -0
- lisa_harness_kernel-0.1.0/tests/test_turn_runner_tool_dispatch.py +1150 -0
- lisa_harness_kernel-0.1.0/tests/test_turn_runner_transitions.py +1028 -0
- lisa_harness_kernel-0.1.0/tests/test_turn_runner_workflow_position.py +446 -0
- lisa_harness_kernel-0.1.0/tests/test_turn_scenario_matrix.py +331 -0
- lisa_harness_kernel-0.1.0/tests/test_turn_transcript.py +117 -0
- lisa_harness_kernel-0.1.0/tests/test_workflow_position.py +535 -0
- lisa_harness_kernel-0.1.0/tests/test_workflow_position_progress.py +71 -0
- lisa_harness_kernel-0.1.0/tests/test_workflow_view.py +567 -0
- lisa_harness_kernel-0.1.0/tests/turn_hooks_fold.py +132 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.pyc
|
|
4
|
+
*.pyo
|
|
5
|
+
*.egg-info/
|
|
6
|
+
dist/
|
|
7
|
+
build/
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# Virtual environments
|
|
11
|
+
.venv/
|
|
12
|
+
|
|
13
|
+
# IDE
|
|
14
|
+
.vscode/
|
|
15
|
+
|
|
16
|
+
# OS
|
|
17
|
+
.DS_Store
|
|
18
|
+
Thumbs.db
|
|
19
|
+
|
|
20
|
+
# Testing
|
|
21
|
+
.import_linter_cache/
|
|
22
|
+
.pytest_cache/
|
|
23
|
+
.ruff_cache/
|
|
24
|
+
.mypy_cache/
|
|
25
|
+
.lisa-tmp/
|
|
26
|
+
coverage/
|
|
27
|
+
.coverage
|
|
28
|
+
.coverage.*
|
|
29
|
+
htmlcov/
|
|
30
|
+
|
|
31
|
+
# Outputs
|
|
32
|
+
artifacts/
|
|
33
|
+
.e2e/
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: Kernel Package
|
|
3
|
+
applyTo: "kernel/**/*.py"
|
|
4
|
+
description: Package-specific guidance for kernel work.
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Kernel package guidance
|
|
8
|
+
|
|
9
|
+
- Keep the kernel runtime-neutral. Do not introduce host- or SDK-specific imports into kernel modules.
|
|
10
|
+
- Preserve the existing contract-style abstractions in kernel modules rather than falling back to ad hoc state bags.
|
|
11
|
+
- Follow the slice and turn-runner patterns already established in the package when changing orchestration behavior.
|
|
12
|
+
- Keep handoff, approval, and routing logic explicit and testable.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: lisa-harness-kernel
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Lisa kernel — runtime-neutral FSM engine, contracts, operation registry, and telemetry primitives
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: httpx>=0.27.0
|
|
7
|
+
Requires-Dist: mcp>=2.0.0
|
|
8
|
+
Requires-Dist: opentelemetry-api<2.0.0,>=1.40.0
|
|
9
|
+
Requires-Dist: opentelemetry-sdk<2.0.0,>=1.40.0
|
|
10
|
+
Requires-Dist: pydantic-settings>=2.3.0
|
|
11
|
+
Requires-Dist: pydantic>=2.7.0
|
|
12
|
+
Requires-Dist: python-dotenv>=1.0.0
|
|
13
|
+
Requires-Dist: pyyaml>=6.0
|
|
14
|
+
Requires-Dist: tzdata>=2024.1; platform_system == 'Windows'
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
|
|
17
|
+
Requires-Dist: pytest-cov>=6.0.0; extra == 'dev'
|
|
18
|
+
Requires-Dist: pytest-mock>=3.14.0; extra == 'dev'
|
|
19
|
+
Requires-Dist: pytest>=8.0.0; extra == 'dev'
|
|
20
|
+
Requires-Dist: types-pyyaml>=6.0; extra == 'dev'
|
|
21
|
+
Provides-Extra: memory
|
|
22
|
+
Requires-Dist: sqlite-vec>=0.1.9; extra == 'memory'
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# Kernel
|
|
2
|
+
|
|
3
|
+
`lisa-harness-kernel` owns runtime-neutral typed contracts and the model/tool
|
|
4
|
+
turn loop. It does not depend on another in-repository package. Hosts inject
|
|
5
|
+
wiring through `TurnHooks` and `ContextSeams`; packs provide concrete
|
|
6
|
+
capabilities.
|
|
7
|
+
|
|
8
|
+
Start with [architecture and terminology](../docs/BOUNDARIES.md).
|
|
9
|
+
Most application authors should use the [shared runtime](../runtime/README.md)
|
|
10
|
+
rather than build a new composition root directly against kernel internals.
|
|
11
|
+
|
|
12
|
+
## Module map
|
|
13
|
+
|
|
14
|
+
Paths below are relative to [src/kernel](src/kernel/).
|
|
15
|
+
|
|
16
|
+
| Module | Responsibility |
|
|
17
|
+
| --- | --- |
|
|
18
|
+
| [turn_runner](src/kernel/turn_runner/) | Shared completion/dispatch loop, transitions, continuations, outcomes |
|
|
19
|
+
| [turn_phases](src/kernel/turn_phases/) | Lifecycle, prompts/schemas, routing hints, fork/delegate and completion contracts |
|
|
20
|
+
| [turn_hooks](src/kernel/turn_hooks.py) | Host-injected callbacks and context seams |
|
|
21
|
+
| [models](src/kernel/models.py) | Execution contexts/results, modes/stages, receipts, state/evidence, budgets |
|
|
22
|
+
| [ipsar](src/kernel/ipsar.py) / [ipsar_fsm](src/kernel/ipsar_fsm.py) | Mode command parsing, catalogs, workflow/routing transitions |
|
|
23
|
+
| [session_plan](src/kernel/session_plan.py) | Strict v2 schema and deterministic compilation |
|
|
24
|
+
| [context](src/kernel/context/) | Evidence mapping, lanes, provenance, request fitting, public seam defaults |
|
|
25
|
+
| [operations](src/kernel/operations/) | Runtime/declarative operation discovery and execution |
|
|
26
|
+
| [tools](src/kernel/tools/) | Pack-tool registry, MCP pool, schema translation and synthetic aliases |
|
|
27
|
+
| [seams](src/kernel/seams/) | Host-facing protocols and adapters |
|
|
28
|
+
| [model_manifest](src/kernel/model_manifest.py) | Model roster validation and resolution |
|
|
29
|
+
| [provider_contracts](src/kernel/provider_contracts.py) | Completion/embedding contracts and provider-neutral usage |
|
|
30
|
+
| [execution_policy](src/kernel/execution_policy.py) / [execution_ledger](src/kernel/execution_ledger.py) | Resource bounds and consumed-work accounting |
|
|
31
|
+
| [approval](src/kernel/approval.py) / [turn_continuation](src/kernel/turn_continuation.py) | Exact-action decisions and saved dispatch state |
|
|
32
|
+
| [recovery](src/kernel/recovery.py) | Write-ahead work-item state and retry policy |
|
|
33
|
+
| [workflow_position](src/kernel/workflow_position.py) / [workflow_view](src/kernel/workflow_view.py) | Typed planned/ad-hoc progress and thread-derived workflow state |
|
|
34
|
+
| [slice](src/kernel/slice.py) | Pressure-derived opportunistic housekeeping budgets |
|
|
35
|
+
| [intake](src/kernel/intake/) | Signal collector and processing contracts |
|
|
36
|
+
| [testing](src/kernel/testing/) | Public stubs, extension harness, import-boundary support |
|
|
37
|
+
|
|
38
|
+
Provider implementations live in the SDK; the kernel defines/discovers
|
|
39
|
+
their gateway contracts. Content scanning lives in the SDK/catalog adapter;
|
|
40
|
+
the kernel consumes catalogs and imports selected implementation modules.
|
|
41
|
+
|
|
42
|
+
## Turn contract
|
|
43
|
+
|
|
44
|
+
`run_turn` accepts a query, resolved mode/model context, budgets, hooks,
|
|
45
|
+
and identity. The runtime normally prepares those inputs.
|
|
46
|
+
|
|
47
|
+
1. Guard input and establish mode/workflow context.
|
|
48
|
+
2. Run configured entry/pre-turn behavior, assemble instructions/evidence,
|
|
49
|
+
and prepare the allowed tool schemas.
|
|
50
|
+
3. Resolve each iteration's model, fit the complete request, call it, and
|
|
51
|
+
account for provider usage.
|
|
52
|
+
4. Dispatch authorized operations, pack tools, MCP tools, or intercepted
|
|
53
|
+
lifecycle actions; record outcomes and effect receipts.
|
|
54
|
+
5. Evaluate explicit lifecycle policy: continue, transition, fork/delegate,
|
|
55
|
+
ask/yield, approval, pause/terminate, or complete.
|
|
56
|
+
6. Guard output and run completion/persistence/post-turn hooks as applicable.
|
|
57
|
+
|
|
58
|
+
Do not equate an answer string with successful action completion.
|
|
59
|
+
`TurnResult` carries blocks, handoffs, stage transitions, child outcomes,
|
|
60
|
+
approvals/yields, usage, receipts, and evidence needed by the host.
|
|
61
|
+
|
|
62
|
+
See [mode/workflow routing](../docs/ROUTER_DESIGN.md),
|
|
63
|
+
[context lifecycle](../docs/CONTEXT_LIFECYCLE.md),
|
|
64
|
+
[token budgets](../docs/TOKEN_BUDGET.md), and the
|
|
65
|
+
[invariant registry](../docs/lessons/turn-loop-invariants.md).
|
|
66
|
+
|
|
67
|
+
## TurnHooks and ContextSeams
|
|
68
|
+
|
|
69
|
+
[TurnHooks](src/kernel/turn_hooks.py) supplies model resolution/completion,
|
|
70
|
+
prompt assembly, authorized operation/tool execution, catalogs/pool access,
|
|
71
|
+
state/services/identity, workflow updates, persistence, and slices.
|
|
72
|
+
Callbacks are integration seams, not permission to bypass registry policy.
|
|
73
|
+
|
|
74
|
+
[`ContextSeams`](src/kernel/turn_runner/_seams.py) groups content registry, model policy, provider gateways,
|
|
75
|
+
event bus, extension-item builder, and input/output guards.
|
|
76
|
+
Public defaults live in [kernel.context](src/kernel/context/).
|
|
77
|
+
Default content guards are **pass-through**. Applications needing filtering
|
|
78
|
+
must install appropriate implementations.
|
|
79
|
+
|
|
80
|
+
Input blocks prevent model work. Output guards can replace unsafe output;
|
|
81
|
+
hosts must persist/deliver the permitted result, not leak the rejected text.
|
|
82
|
+
Privacy also depends on authenticated identity, storage/retrieval filters,
|
|
83
|
+
and delivery routing; it cannot be implemented by a prompt alone.
|
|
84
|
+
|
|
85
|
+
For a custom composition, follow [host integration](../docs/HOST_INTEGRATION.md).
|
|
86
|
+
Keep MCP lifecycle startup/use/shutdown in one task and event loop.
|
|
87
|
+
|
|
88
|
+
## Dispatch and operation contracts
|
|
89
|
+
|
|
90
|
+
Operations use `OperationExecutionContext` and return `OperationResult`.
|
|
91
|
+
They can be Python (`.yml` + `.py`) or declarative (`.operation.md`).
|
|
92
|
+
Pack tools use `ToolExecutionContext` / `ToolResult`.
|
|
93
|
+
Operations interact with the host through `ctx.host`; do not use retired
|
|
94
|
+
`ctx.runtime` examples.
|
|
95
|
+
|
|
96
|
+
The kernel authorizes catalog capabilities, validates effects/limits, and
|
|
97
|
+
uses declared tool surfaces for operation dispatch. Exposing a capability
|
|
98
|
+
as an ambient default does not make it a required completion effect.
|
|
99
|
+
Markdown operation definitions take precedence over a colocated Python
|
|
100
|
+
definition; the Python file can supply the response model.
|
|
101
|
+
|
|
102
|
+
External tools use the MCP pool; catalog/content access uses the content
|
|
103
|
+
seams. In-process Python tools remain trusted code, not sandboxed merely
|
|
104
|
+
because they were discovered through MCP.
|
|
105
|
+
|
|
106
|
+
Use [operation authoring](../sdk/AUTHORING.md),
|
|
107
|
+
[pack authoring](../docs/PACK_AUTHORING_GUIDE.md), and
|
|
108
|
+
[approval policy](../docs/POLICY_GUIDE.md) for metadata and examples.
|
|
109
|
+
|
|
110
|
+
## Slices and intake
|
|
111
|
+
|
|
112
|
+
Slices are opportunistic, budgeted work. Budget grows with the elapsed gap
|
|
113
|
+
since the previous slice:
|
|
114
|
+
|
|
115
|
+
```text
|
|
116
|
+
base_budget_ms + max_deferred_ms * (1 - 0.5^(gap_seconds / pressure_half_life_seconds))
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
The result is capped. Policies select pre-turn, post-turn, explicit, or
|
|
120
|
+
disabled execution. This is not a continuously running scheduler.
|
|
121
|
+
Hosts can separately schedule the one-shot intake process for ambient
|
|
122
|
+
signal collection and processing; no cron/timer belongs inside the turn loop.
|
|
123
|
+
|
|
124
|
+
## Testing and contribution
|
|
125
|
+
|
|
126
|
+
From the workspace root:
|
|
127
|
+
|
|
128
|
+
```sh
|
|
129
|
+
uv run pytest kernel/tests kernel/src/kernel/context/tests -m "not live" -q
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
The kernel test support is public for pack/extension authors. Use injected
|
|
133
|
+
fakes and isolated configuration; do not import private app modules or live
|
|
134
|
+
external packs into kernel unit tests.
|
|
135
|
+
|
|
136
|
+
See [testing](../docs/TESTING.md) for the full CI matrix/gates and
|
|
137
|
+
[contributing](../CONTRIBUTING.md).
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[tool.hatch.build.targets.wheel]
|
|
6
|
+
packages = ["src/kernel"]
|
|
7
|
+
|
|
8
|
+
[project]
|
|
9
|
+
name = "lisa-harness-kernel"
|
|
10
|
+
version = "0.1.0"
|
|
11
|
+
description = "Lisa kernel — runtime-neutral FSM engine, contracts, operation registry, and telemetry primitives"
|
|
12
|
+
requires-python = ">=3.11"
|
|
13
|
+
|
|
14
|
+
dependencies = [
|
|
15
|
+
"pydantic>=2.7.0",
|
|
16
|
+
"pydantic-settings>=2.3.0",
|
|
17
|
+
"pyyaml>=6.0",
|
|
18
|
+
"opentelemetry-api>=1.40.0,<2.0.0",
|
|
19
|
+
"opentelemetry-sdk>=1.40.0,<2.0.0",
|
|
20
|
+
"httpx>=0.27.0",
|
|
21
|
+
"python-dotenv>=1.0.0",
|
|
22
|
+
"mcp>=2.0.0",
|
|
23
|
+
# On Windows, Python's zoneinfo has no system tz database, so the
|
|
24
|
+
# tzdata package is required for IANA timezone resolution.
|
|
25
|
+
"tzdata>=2024.1; platform_system == 'Windows'",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.optional-dependencies]
|
|
29
|
+
dev = [
|
|
30
|
+
"pytest>=8.0.0",
|
|
31
|
+
"pytest-asyncio>=0.23.0",
|
|
32
|
+
"pytest-cov>=6.0.0",
|
|
33
|
+
"pytest-mock>=3.14.0",
|
|
34
|
+
"types-PyYAML>=6.0",
|
|
35
|
+
]
|
|
36
|
+
memory = [
|
|
37
|
+
"sqlite-vec>=0.1.9",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
[tool.pytest.ini_options]
|
|
41
|
+
pythonpath = ["src"]
|
|
42
|
+
testpaths = ["tests"]
|
|
43
|
+
markers = [
|
|
44
|
+
"live: hits real external signal or provider APIs and prints a tester-visible summary",
|
|
45
|
+
"integration: multi-module in-process test; may require local services",
|
|
46
|
+
"eval: deterministic agent regression evals (no model calls, no I/O)",
|
|
47
|
+
]
|
|
@@ -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
|
+
]
|