monoid-agent-kernel 0.16.1__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 (191) hide show
  1. monoid_agent_kernel/__init__.py +11 -0
  2. monoid_agent_kernel/_policy_util.py +40 -0
  3. monoid_agent_kernel/_proc.py +86 -0
  4. monoid_agent_kernel/builder.py +351 -0
  5. monoid_agent_kernel/cli.py +1244 -0
  6. monoid_agent_kernel/conformance/__init__.py +32 -0
  7. monoid_agent_kernel/conformance/harness.py +209 -0
  8. monoid_agent_kernel/conformance/profiles/__init__.py +55 -0
  9. monoid_agent_kernel/conformance/profiles/_metadata.py +16 -0
  10. monoid_agent_kernel/conformance/profiles/capability_security.py +216 -0
  11. monoid_agent_kernel/conformance/profiles/control_plane.py +41 -0
  12. monoid_agent_kernel/conformance/profiles/durable_runner.py +42 -0
  13. monoid_agent_kernel/conformance/profiles/message_fabric.py +42 -0
  14. monoid_agent_kernel/conformance/profiles/minimal_agent.py +13 -0
  15. monoid_agent_kernel/conformance/profiles/multi_agent.py +112 -0
  16. monoid_agent_kernel/conformance/profiles/provider_gateway.py +107 -0
  17. monoid_agent_kernel/conformance/profiles/reference_full.py +135 -0
  18. monoid_agent_kernel/conformance/profiles/side_effect_tool_agent.py +36 -0
  19. monoid_agent_kernel/conformance/profiles/tool_agent.py +39 -0
  20. monoid_agent_kernel/contracts.py +329 -0
  21. monoid_agent_kernel/core/__init__.py +1 -0
  22. monoid_agent_kernel/core/_util.py +145 -0
  23. monoid_agent_kernel/core/agents.py +883 -0
  24. monoid_agent_kernel/core/cancellation.py +16 -0
  25. monoid_agent_kernel/core/capability.py +376 -0
  26. monoid_agent_kernel/core/capability_revocation.py +92 -0
  27. monoid_agent_kernel/core/checkpoint.py +350 -0
  28. monoid_agent_kernel/core/content.py +101 -0
  29. monoid_agent_kernel/core/context.py +67 -0
  30. monoid_agent_kernel/core/control.py +124 -0
  31. monoid_agent_kernel/core/control_audit.py +99 -0
  32. monoid_agent_kernel/core/durable_metadata.py +116 -0
  33. monoid_agent_kernel/core/event_sequencing.py +164 -0
  34. monoid_agent_kernel/core/events.py +204 -0
  35. monoid_agent_kernel/core/external_agent_envelope.py +338 -0
  36. monoid_agent_kernel/core/frontmatter.py +140 -0
  37. monoid_agent_kernel/core/inbox.py +110 -0
  38. monoid_agent_kernel/core/lease_admission.py +57 -0
  39. monoid_agent_kernel/core/lifecycle.py +440 -0
  40. monoid_agent_kernel/core/manifest.py +88 -0
  41. monoid_agent_kernel/core/media.py +393 -0
  42. monoid_agent_kernel/core/outbox.py +212 -0
  43. monoid_agent_kernel/core/output_validator.py +103 -0
  44. monoid_agent_kernel/core/packages.py +742 -0
  45. monoid_agent_kernel/core/projections.py +213 -0
  46. monoid_agent_kernel/core/prompt.py +37 -0
  47. monoid_agent_kernel/core/proposal_file.py +84 -0
  48. monoid_agent_kernel/core/result.py +131 -0
  49. monoid_agent_kernel/core/schemas.py +1146 -0
  50. monoid_agent_kernel/core/scope.py +185 -0
  51. monoid_agent_kernel/core/side_effect_policy.py +153 -0
  52. monoid_agent_kernel/core/spec.py +325 -0
  53. monoid_agent_kernel/core/streaming.py +208 -0
  54. monoid_agent_kernel/core/subagent_runtime.py +187 -0
  55. monoid_agent_kernel/core/tool_approval.py +175 -0
  56. monoid_agent_kernel/core/tool_surface.py +505 -0
  57. monoid_agent_kernel/core/trace_context.py +76 -0
  58. monoid_agent_kernel/core/wire_validation.py +156 -0
  59. monoid_agent_kernel/core/workspace.py +161 -0
  60. monoid_agent_kernel/core/workspace_index.py +127 -0
  61. monoid_agent_kernel/env.py +32 -0
  62. monoid_agent_kernel/errors.py +105 -0
  63. monoid_agent_kernel/event_loader.py +47 -0
  64. monoid_agent_kernel/identifiers.py +50 -0
  65. monoid_agent_kernel/loop.py +4140 -0
  66. monoid_agent_kernel/loop_phases.py +561 -0
  67. monoid_agent_kernel/mcp/__init__.py +10 -0
  68. monoid_agent_kernel/mcp/client.py +227 -0
  69. monoid_agent_kernel/mcp/provider.py +407 -0
  70. monoid_agent_kernel/narration.py +92 -0
  71. monoid_agent_kernel/observability/__init__.py +9 -0
  72. monoid_agent_kernel/observability/otel.py +264 -0
  73. monoid_agent_kernel/permissions.py +74 -0
  74. monoid_agent_kernel/providers/__init__.py +7 -0
  75. monoid_agent_kernel/providers/_common.py +122 -0
  76. monoid_agent_kernel/providers/base.py +312 -0
  77. monoid_agent_kernel/providers/fake.py +76 -0
  78. monoid_agent_kernel/providers/gateway.py +474 -0
  79. monoid_agent_kernel/providers/openai.py +487 -0
  80. monoid_agent_kernel/public_view.py +173 -0
  81. monoid_agent_kernel/py.typed +0 -0
  82. monoid_agent_kernel/recorder.py +421 -0
  83. monoid_agent_kernel/reference/__init__.py +15 -0
  84. monoid_agent_kernel/reference/_shared/__init__.py +4 -0
  85. monoid_agent_kernel/reference/_shared/http_util.py +111 -0
  86. monoid_agent_kernel/reference/_shared/tokens.py +314 -0
  87. monoid_agent_kernel/reference/backend/__init__.py +21 -0
  88. monoid_agent_kernel/reference/backend/commands.py +375 -0
  89. monoid_agent_kernel/reference/backend/http.py +439 -0
  90. monoid_agent_kernel/reference/backend/jobs.py +68 -0
  91. monoid_agent_kernel/reference/backend/loop_factory.py +253 -0
  92. monoid_agent_kernel/reference/backend/outbox_dispatch.py +130 -0
  93. monoid_agent_kernel/reference/backend/ports.py +188 -0
  94. monoid_agent_kernel/reference/backend/projection.py +286 -0
  95. monoid_agent_kernel/reference/backend/proposal.py +220 -0
  96. monoid_agent_kernel/reference/backend/proposal_reader.py +16 -0
  97. monoid_agent_kernel/reference/backend/recovery.py +253 -0
  98. monoid_agent_kernel/reference/backend/run_execution.py +152 -0
  99. monoid_agent_kernel/reference/backend/run_preparation.py +197 -0
  100. monoid_agent_kernel/reference/backend/run_state.py +243 -0
  101. monoid_agent_kernel/reference/backend/run_types.py +128 -0
  102. monoid_agent_kernel/reference/backend/runtime_config.py +147 -0
  103. monoid_agent_kernel/reference/backend/service.py +1664 -0
  104. monoid_agent_kernel/reference/backend/session.py +280 -0
  105. monoid_agent_kernel/reference/backend/session_drive.py +231 -0
  106. monoid_agent_kernel/reference/capability.py +101 -0
  107. monoid_agent_kernel/reference/conformance.py +1777 -0
  108. monoid_agent_kernel/reference/llm_gateway/__init__.py +14 -0
  109. monoid_agent_kernel/reference/llm_gateway/http.py +225 -0
  110. monoid_agent_kernel/reference/llm_gateway/providers.py +96 -0
  111. monoid_agent_kernel/reference/llm_gateway/service.py +404 -0
  112. monoid_agent_kernel/reference/mcp_gateway/__init__.py +26 -0
  113. monoid_agent_kernel/reference/mcp_gateway/http.py +187 -0
  114. monoid_agent_kernel/reference/mcp_gateway/service.py +206 -0
  115. monoid_agent_kernel/reference/outbox.py +135 -0
  116. monoid_agent_kernel/reference/stores/__init__.py +20 -0
  117. monoid_agent_kernel/reference/stores/lease.py +116 -0
  118. monoid_agent_kernel/reference/stores/sqlite.py +295 -0
  119. monoid_agent_kernel/reference/studio/README.md +97 -0
  120. monoid_agent_kernel/reference/studio/__init__.py +21 -0
  121. monoid_agent_kernel/reference/studio/activity.py +53 -0
  122. monoid_agent_kernel/reference/studio/cli.py +481 -0
  123. monoid_agent_kernel/reference/studio/sample-skills/code-review-checklist/SKILL.md +25 -0
  124. monoid_agent_kernel/reference/studio/sample-skills/code-review-checklist/references/review-checklist.md +8 -0
  125. monoid_agent_kernel/reference/studio/sample-skills/commit-message/SKILL.md +32 -0
  126. monoid_agent_kernel/reference/studio/sample-skills/incident-summary/SKILL.md +24 -0
  127. monoid_agent_kernel/reference/studio/sample-skills/incident-summary/references/incident-template.md +32 -0
  128. monoid_agent_kernel/reference/studio/sample-skills/release-notes/SKILL.md +24 -0
  129. monoid_agent_kernel/reference/studio/sample-skills/release-notes/references/release-note-template.md +33 -0
  130. monoid_agent_kernel/reference/studio/sample-skills/release-notes/scripts/collect_changes.py +51 -0
  131. monoid_agent_kernel/reference/studio/server.py +1922 -0
  132. monoid_agent_kernel/reference/studio/web/index.html +1877 -0
  133. monoid_agent_kernel/reference/studio/web/settings.html +108 -0
  134. monoid_agent_kernel/reference/studio/web/vendor/katex/auto-render.min.js +1 -0
  135. monoid_agent_kernel/reference/studio/web/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  136. monoid_agent_kernel/reference/studio/web/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  137. monoid_agent_kernel/reference/studio/web/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  138. monoid_agent_kernel/reference/studio/web/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  139. monoid_agent_kernel/reference/studio/web/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  140. monoid_agent_kernel/reference/studio/web/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  141. monoid_agent_kernel/reference/studio/web/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  142. monoid_agent_kernel/reference/studio/web/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  143. monoid_agent_kernel/reference/studio/web/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  144. monoid_agent_kernel/reference/studio/web/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  145. monoid_agent_kernel/reference/studio/web/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  146. monoid_agent_kernel/reference/studio/web/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  147. monoid_agent_kernel/reference/studio/web/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  148. monoid_agent_kernel/reference/studio/web/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  149. monoid_agent_kernel/reference/studio/web/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  150. monoid_agent_kernel/reference/studio/web/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  151. monoid_agent_kernel/reference/studio/web/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  152. monoid_agent_kernel/reference/studio/web/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  153. monoid_agent_kernel/reference/studio/web/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  154. monoid_agent_kernel/reference/studio/web/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  155. monoid_agent_kernel/reference/studio/web/vendor/katex/katex.min.css +1 -0
  156. monoid_agent_kernel/reference/studio/web/vendor/katex/katex.min.js +1 -0
  157. monoid_agent_kernel/reference/studio/window.py +72 -0
  158. monoid_agent_kernel/reference/web_gateway/__init__.py +6 -0
  159. monoid_agent_kernel/reference/web_gateway/http.py +155 -0
  160. monoid_agent_kernel/reference/web_gateway/providers.py +807 -0
  161. monoid_agent_kernel/reference/web_gateway/service.py +559 -0
  162. monoid_agent_kernel/shell.py +722 -0
  163. monoid_agent_kernel/skills/__init__.py +20 -0
  164. monoid_agent_kernel/skills/definition.py +82 -0
  165. monoid_agent_kernel/skills/loader.py +55 -0
  166. monoid_agent_kernel/skills/provider.py +378 -0
  167. monoid_agent_kernel/subagent_loader.py +56 -0
  168. monoid_agent_kernel/tasks.py +1326 -0
  169. monoid_agent_kernel/tool_loader.py +51 -0
  170. monoid_agent_kernel/tool_services/__init__.py +8 -0
  171. monoid_agent_kernel/tool_services/base.py +25 -0
  172. monoid_agent_kernel/tool_services/jobs.py +47 -0
  173. monoid_agent_kernel/tool_services/shell.py +255 -0
  174. monoid_agent_kernel/tool_services/web.py +356 -0
  175. monoid_agent_kernel/tools/__init__.py +2 -0
  176. monoid_agent_kernel/tools/base.py +205 -0
  177. monoid_agent_kernel/tools/builtin.py +958 -0
  178. monoid_agent_kernel/tools/decorator.py +132 -0
  179. monoid_agent_kernel/tools/tool_ids.py +62 -0
  180. monoid_agent_kernel/web.py +171 -0
  181. monoid_agent_kernel/workspace/__init__.py +2 -0
  182. monoid_agent_kernel/workspace/local.py +1004 -0
  183. monoid_agent_kernel/workspace/paths.py +30 -0
  184. monoid_agent_kernel-0.16.1.dist-info/METADATA +709 -0
  185. monoid_agent_kernel-0.16.1.dist-info/RECORD +191 -0
  186. monoid_agent_kernel-0.16.1.dist-info/WHEEL +4 -0
  187. monoid_agent_kernel-0.16.1.dist-info/entry_points.txt +3 -0
  188. monoid_agent_kernel-0.16.1.dist-info/licenses/LICENSE +202 -0
  189. monoid_agent_kernel-0.16.1.dist-info/licenses/NOTICE +8 -0
  190. native_agent_runner/__init__.py +103 -0
  191. native_agent_runner/py.typed +0 -0
@@ -0,0 +1,1244 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import time
5
+ from dataclasses import replace
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import click
10
+
11
+ from monoid_agent_kernel.core.agents import (
12
+ AgentDefinition,
13
+ AgentRuntimeConfig,
14
+ StaticRuntimeConfigProvider,
15
+ )
16
+ from monoid_agent_kernel.reference.backend.http import create_backend_server
17
+ from monoid_agent_kernel.reference.backend.service import RunnerBackend
18
+ from monoid_agent_kernel.reference._shared.tokens import TokenManager
19
+ from monoid_agent_kernel.narration import narrate_event
20
+ from monoid_agent_kernel.core.spec import (
21
+ AgentRunSpec,
22
+ ModelConfig,
23
+ RunLimits,
24
+ )
25
+ from monoid_agent_kernel.core.schemas import validate_run_dir
26
+ from monoid_agent_kernel.core.packages import (
27
+ apply_package,
28
+ create_approval,
29
+ export_package,
30
+ import_package,
31
+ inspect_package,
32
+ verify_package,
33
+ write_apply_result,
34
+ write_approval,
35
+ )
36
+ from monoid_agent_kernel.core.projections import project_run_status
37
+ from monoid_agent_kernel.core.proposal_file import ProposalFileError, read_proposal_file_payload
38
+ from monoid_agent_kernel.event_loader import load_event_sinks
39
+ from monoid_agent_kernel.tasks import (
40
+ get_job_artifact,
41
+ list_job_artifacts,
42
+ read_job_log_text,
43
+ request_job_cancel,
44
+ )
45
+ from monoid_agent_kernel.reference.llm_gateway.http import create_llm_gateway_server
46
+ from monoid_agent_kernel.reference.llm_gateway.providers import offline_provider_factory
47
+ from monoid_agent_kernel.reference.llm_gateway.service import LlmGatewayBackend
48
+ from monoid_agent_kernel.loop import AgentLoop
49
+ from monoid_agent_kernel.permissions import PermissionPolicy
50
+ from monoid_agent_kernel.providers.base import ModelAdapter
51
+ from monoid_agent_kernel.providers.gateway import GatewayModelAdapter
52
+ from monoid_agent_kernel.providers.openai import OpenAIModelAdapter
53
+ from monoid_agent_kernel.recorder import StdoutJsonlSink, append_event_to_run
54
+ from monoid_agent_kernel.skills import SkillProvider, load_skill_definitions
55
+ from monoid_agent_kernel.subagent_loader import load_subagent_definitions
56
+ from monoid_agent_kernel.tool_loader import load_capability_broker, load_tool_provider
57
+ from monoid_agent_kernel.core.capability import AutoGrantBroker
58
+ from monoid_agent_kernel.env import env_name_for_error, getenv
59
+ from monoid_agent_kernel.web import WebGatewayClient
60
+ from monoid_agent_kernel.reference.web_gateway.http import create_web_gateway_server
61
+ from monoid_agent_kernel.reference.web_gateway.providers import (
62
+ BraveLlmContextProvider,
63
+ BraveSearchProvider,
64
+ CompositeWebProvider,
65
+ HttpFetchProvider,
66
+ SearchFetchContextProvider,
67
+ )
68
+ from monoid_agent_kernel.reference.web_gateway.service import FakeWebProvider, WebGatewayBackend
69
+ from monoid_agent_kernel.reference.studio.cli import studio as studio_group
70
+ from monoid_agent_kernel.builder import builder_group
71
+
72
+
73
+ @click.group()
74
+ def main() -> None:
75
+ """Run Monoid Agent Kernel."""
76
+
77
+
78
+ main.add_command(studio_group)
79
+ main.add_command(builder_group)
80
+
81
+
82
+ @main.command()
83
+ @click.option(
84
+ "--spec",
85
+ "spec_file",
86
+ type=click.Path(path_type=Path),
87
+ default=None,
88
+ help=(
89
+ "Load run-specific values from a JSON file (AgentRunSpec.to_json shape). "
90
+ "When set, individual spec flags are ignored; runtime flags "
91
+ "(runtime config, gateway URLs/tokens, --event-sink-module, --stream-json, "
92
+ "--no-status-file, --tool-module) still apply."
93
+ ),
94
+ )
95
+ @click.option("--agent-definition-file", type=click.Path(path_type=Path), default=None)
96
+ @click.option("--runtime-config-file", type=click.Path(path_type=Path), default=None)
97
+ @click.option("--workspace", type=click.Path(path_type=Path), default=None)
98
+ @click.option("--instruction", type=str, default="")
99
+ @click.option("--instruction-file", type=click.Path(path_type=Path), default=None)
100
+ @click.option("--llm-gateway-url", type=str, default=None, help="Internal LLM gateway URL.")
101
+ @click.option(
102
+ "--llm-gateway-token-env",
103
+ type=str,
104
+ default="MONOID_LLM_GATEWAY_TOKEN",
105
+ show_default=True,
106
+ help="Environment variable containing a short-lived gateway token.",
107
+ )
108
+ @click.option(
109
+ "--llm-gateway-token-file",
110
+ type=click.Path(path_type=Path),
111
+ default=None,
112
+ help="File containing a short-lived gateway token.",
113
+ )
114
+ @click.option(
115
+ "--allow-direct-provider-api",
116
+ is_flag=True,
117
+ help="Allow direct provider API access for local smoke tests only.",
118
+ )
119
+ @click.option(
120
+ "--mode",
121
+ type=click.Choice(["read-only", "propose", "apply"]),
122
+ default="propose",
123
+ show_default=True,
124
+ )
125
+ @click.option(
126
+ "--workspace-backend",
127
+ type=click.Choice(["overlay", "staging"]),
128
+ default="overlay",
129
+ show_default=True,
130
+ )
131
+ @click.option("--run-root", type=click.Path(path_type=Path), default=Path("runs"), show_default=True)
132
+ @click.option("--run-id", type=str, default=None, help="Use a specific run id.")
133
+ @click.option("--max-steps", type=int, default=30, show_default=True)
134
+ @click.option("--max-tool-calls", type=int, default=100, show_default=True)
135
+ @click.option("--max-bytes-read", type=int, default=1_000_000, show_default=True)
136
+ @click.option("--max-duration-s", type=int, default=900, show_default=True)
137
+ @click.option("--tool-module", multiple=True, help="Load custom tools from path.py:function.")
138
+ @click.option(
139
+ "--agents-directory",
140
+ type=click.Path(path_type=Path),
141
+ default=None,
142
+ help="Load subagent definitions (*.md with frontmatter) from a directory, enabling agent.spawn.",
143
+ )
144
+ @click.option(
145
+ "--skills-directory",
146
+ type=click.Path(path_type=Path),
147
+ default=None,
148
+ help="Load Agent Skills (SKILL.md with frontmatter) from a directory, enabling the skill tools.",
149
+ )
150
+ @click.option(
151
+ "--capability-broker",
152
+ type=str,
153
+ default=None,
154
+ help="Load a CapabilityBroker from path.py:factory to gate tools that declare requires_lease.",
155
+ )
156
+ @click.option(
157
+ "--auto-grant-capabilities",
158
+ is_flag=True,
159
+ help="Use the built-in AutoGrantBroker (local dev): grant any requires_lease tool, scoped to its binding.",
160
+ )
161
+ @click.option("--deny-path", multiple=True, help="Deny workspace paths matching a backend-provided glob.")
162
+ @click.option("--redact-path", multiple=True, help="Redact matching paths from public events and projections.")
163
+ @click.option("--permission-policy-file", type=click.Path(path_type=Path), default=None)
164
+ @click.option("--web-gateway-url", type=str, default=None, help="Internal WebGateway base URL.")
165
+ @click.option(
166
+ "--web-gateway-token-env",
167
+ type=str,
168
+ default="MONOID_WEB_GATEWAY_TOKEN",
169
+ show_default=True,
170
+ help="Environment variable containing a short-lived WebGateway token.",
171
+ )
172
+ @click.option(
173
+ "--web-gateway-token-file",
174
+ type=click.Path(path_type=Path),
175
+ default=None,
176
+ help="File containing a short-lived WebGateway token.",
177
+ )
178
+ @click.option("--event-sink-module", multiple=True, help="Load custom event sinks from path.py:function.")
179
+ @click.option("--stream-json", is_flag=True, help="Stream public events as JSONL on stdout.")
180
+ @click.option("--no-status-file", is_flag=True, help="Disable status.json updates.")
181
+ @click.pass_context
182
+ def run(
183
+ ctx: click.Context,
184
+ *,
185
+ spec_file: Path | None,
186
+ agent_definition_file: Path | None,
187
+ runtime_config_file: Path | None,
188
+ workspace: Path | None,
189
+ instruction: str,
190
+ instruction_file: Path | None,
191
+ llm_gateway_url: str | None,
192
+ llm_gateway_token_env: str,
193
+ llm_gateway_token_file: Path | None,
194
+ allow_direct_provider_api: bool,
195
+ mode: str,
196
+ workspace_backend: str,
197
+ run_root: Path,
198
+ run_id: str | None,
199
+ max_steps: int,
200
+ max_tool_calls: int,
201
+ max_bytes_read: int,
202
+ max_duration_s: int,
203
+ tool_module: tuple[str, ...],
204
+ agents_directory: Path | None,
205
+ skills_directory: Path | None,
206
+ capability_broker: str | None,
207
+ auto_grant_capabilities: bool,
208
+ deny_path: tuple[str, ...],
209
+ redact_path: tuple[str, ...],
210
+ permission_policy_file: Path | None,
211
+ web_gateway_url: str | None,
212
+ web_gateway_token_env: str,
213
+ web_gateway_token_file: Path | None,
214
+ event_sink_module: tuple[str, ...],
215
+ stream_json: bool,
216
+ no_status_file: bool,
217
+ ) -> None:
218
+ """Run an agent against a local workspace."""
219
+ del ctx
220
+ runtime_config = _load_agent_runtime_config(runtime_config_file, agent_definition_file)
221
+ # The instruction is the first user turn, delivered via run_once(); the spec no
222
+ # longer carries it, so it is required for both --spec and --workspace paths.
223
+ if instruction_file is not None:
224
+ instruction = instruction_file.read_text(encoding="utf-8")
225
+ if not instruction.strip():
226
+ raise click.ClickException("--instruction or --instruction-file is required")
227
+ if spec_file is not None:
228
+ if workspace is not None:
229
+ raise click.ClickException("--spec cannot be combined with --workspace; the spec file is authoritative")
230
+ try:
231
+ spec = AgentRunSpec.from_json(json.loads(spec_file.read_text(encoding="utf-8")))
232
+ except Exception as exc:
233
+ raise click.ClickException(f"failed to load --spec: {exc}") from exc
234
+ if run_id is not None:
235
+ spec = replace(spec, run_id=run_id)
236
+ else:
237
+ if workspace is None:
238
+ raise click.ClickException("--workspace (or --spec) is required")
239
+
240
+ resolved_limits = RunLimits(
241
+ max_steps=max_steps,
242
+ max_tool_calls=max_tool_calls,
243
+ max_bytes_read=max_bytes_read,
244
+ max_duration_s=max_duration_s,
245
+ )
246
+
247
+ try:
248
+ permission_policy = _load_permission_policy(
249
+ permission_policy_file,
250
+ deny_path=deny_path,
251
+ redact_path=redact_path,
252
+ )
253
+ except Exception as exc:
254
+ raise click.ClickException(str(exc)) from exc
255
+
256
+ spec_kwargs: dict[str, Any] = {}
257
+ if run_id is not None:
258
+ spec_kwargs["run_id"] = run_id
259
+ spec = AgentRunSpec(
260
+ workspace_root=workspace,
261
+ run_root=run_root,
262
+ mode=mode, # type: ignore[arg-type]
263
+ workspace_backend=workspace_backend, # type: ignore[arg-type]
264
+ limits=resolved_limits,
265
+ permission_policy=permission_policy,
266
+ **spec_kwargs,
267
+ )
268
+
269
+ if _runtime_config_uses_web(runtime_config) and not web_gateway_url:
270
+ raise click.ClickException(
271
+ "runtime config binds web tools; --web-gateway-url is required"
272
+ )
273
+ _human_echo(f"run_id: {spec.run_id}", stream_json=stream_json)
274
+ _human_echo(f"run_dir: {spec.run_root / spec.run_id}", stream_json=stream_json)
275
+
276
+ try:
277
+ providers = tuple(load_tool_provider(item) for item in tool_module)
278
+ subagent_definitions = (
279
+ load_subagent_definitions(agents_directory) if agents_directory is not None else {}
280
+ )
281
+ skill_provider: SkillProvider | None = None
282
+ if skills_directory is not None:
283
+ skill_definitions = load_skill_definitions(skills_directory)
284
+ if skill_definitions:
285
+ skill_provider = SkillProvider(skill_definitions)
286
+ # Provider tools are not auto-bound; expose them by merging their bindings
287
+ # into the runtime config (mirrors the MCP provider).
288
+ runtime_config = replace(
289
+ runtime_config, tools=runtime_config.tools + skill_provider.tool_bindings()
290
+ )
291
+ # Fork skills (context: fork) run as subagents; register their synthesized
292
+ # definitions (namespaced ids, so no collision with --agents-directory).
293
+ subagent_definitions = {**subagent_definitions, **skill_provider.subagent_definitions()}
294
+ extra_sinks = []
295
+ if stream_json:
296
+ extra_sinks.append(StdoutJsonlSink())
297
+ for item in event_sink_module:
298
+ extra_sinks.extend(load_event_sinks(item))
299
+ if capability_broker and auto_grant_capabilities:
300
+ raise ValueError("use either --capability-broker or --auto-grant-capabilities, not both")
301
+ broker = (
302
+ load_capability_broker(capability_broker)
303
+ if capability_broker
304
+ else AutoGrantBroker()
305
+ if auto_grant_capabilities
306
+ else None
307
+ )
308
+ except Exception as exc:
309
+ raise click.ClickException(str(exc)) from exc
310
+
311
+ result = AgentLoop(
312
+ spec=spec,
313
+ subagent_definitions=subagent_definitions,
314
+ model_adapter=_model_adapter(
315
+ runtime_config.model or ModelConfig(),
316
+ llm_gateway_url=llm_gateway_url or (runtime_config.model.gateway_url if runtime_config.model else None),
317
+ llm_gateway_token_env=llm_gateway_token_env,
318
+ llm_gateway_token_file=llm_gateway_token_file,
319
+ allow_direct_provider_api=allow_direct_provider_api,
320
+ ),
321
+ tool_providers=providers + ((skill_provider,) if skill_provider is not None else ()),
322
+ context_providers=(skill_provider,) if skill_provider is not None else (),
323
+ capability_broker=broker,
324
+ event_sinks=tuple(extra_sinks),
325
+ status_file=not no_status_file,
326
+ permission_policy=spec.permission_policy,
327
+ runtime_config_provider=StaticRuntimeConfigProvider(runtime_config),
328
+ web_gateway_client=(
329
+ WebGatewayClient(
330
+ web_gateway_url,
331
+ token_env=web_gateway_token_env,
332
+ token_file=web_gateway_token_file,
333
+ )
334
+ if _runtime_config_uses_web(runtime_config) and web_gateway_url
335
+ else None
336
+ ),
337
+ ).run_once(instruction)
338
+ _human_echo(f"status: {result.status}", stream_json=stream_json)
339
+ if result.final_text:
340
+ _human_echo(f"summary: {result.final_text}", stream_json=stream_json)
341
+ if result.error:
342
+ raise click.ClickException(result.error)
343
+
344
+
345
+ @main.command()
346
+ @click.argument("run_dir_or_id", type=str)
347
+ @click.option("--run-root", type=click.Path(path_type=Path), default=Path("runs"), show_default=True)
348
+ @click.option("--from-start", is_flag=True, help="Read events from the beginning of the file.")
349
+ @click.option("--follow", is_flag=True, help="Keep waiting for new events.")
350
+ @click.option("--json", "json_output", is_flag=True, help="Print raw JSONL events.")
351
+ def watch(run_dir_or_id: str, run_root: Path, from_start: bool, follow: bool, json_output: bool) -> None:
352
+ """Watch a run's public events."""
353
+ events_path = _resolve_events_path(run_dir_or_id, run_root)
354
+ if not events_path.exists():
355
+ raise click.ClickException(f"events.jsonl not found: {events_path}")
356
+
357
+ start_from_beginning = from_start or not follow
358
+ with events_path.open("r", encoding="utf-8") as handle:
359
+ if not start_from_beginning:
360
+ handle.seek(0, 2)
361
+ while True:
362
+ line = handle.readline()
363
+ if line:
364
+ click.echo(line.rstrip("\n") if json_output else _compact_event_line(line))
365
+ continue
366
+ if not follow:
367
+ break
368
+ time.sleep(0.25)
369
+
370
+
371
+ @main.command("status")
372
+ @click.argument("run_dir_or_id", type=str)
373
+ @click.option("--run-root", type=click.Path(path_type=Path), default=Path("runs"), show_default=True)
374
+ @click.option("--json", "json_output", is_flag=True, help="Print machine-readable JSON.")
375
+ def status_command(run_dir_or_id: str, run_root: Path, json_output: bool) -> None:
376
+ """Project a run directory into compact status state."""
377
+ run_dir = _resolve_run_dir(run_dir_or_id, run_root)
378
+ payload = project_run_status(run_dir)
379
+ if json_output:
380
+ click.echo(json.dumps(payload, ensure_ascii=False, sort_keys=True))
381
+ return
382
+ click.echo(f"run_id: {payload.get('run_id', '')}")
383
+ click.echo(f"state: {payload.get('state', '')}")
384
+ click.echo(f"terminal: {str(bool(payload.get('terminal'))).lower()}")
385
+ if payload.get("error_code"):
386
+ click.echo(f"error_code: {payload['error_code']}")
387
+ if payload.get("current_step") is not None:
388
+ click.echo(f"current_step: {payload['current_step']}")
389
+ if payload.get("current_tool"):
390
+ click.echo(f"current_tool: {payload['current_tool']}")
391
+ if payload.get("waiting_for_background_jobs"):
392
+ click.echo("waiting_for_background_jobs: true")
393
+ if payload.get("running_jobs"):
394
+ click.echo(f"running_jobs: {len(payload['running_jobs'])}")
395
+ if payload.get("proposal_hash"):
396
+ click.echo(f"proposal_hash: {payload['proposal_hash']}")
397
+ if payload.get("changed_paths"):
398
+ click.echo(f"changed_paths: {', '.join(map(str, payload['changed_paths']))}")
399
+
400
+
401
+ @main.command("jobs")
402
+ @click.argument("run_dir_or_id", type=str)
403
+ @click.option("--run-root", type=click.Path(path_type=Path), default=Path("runs"), show_default=True)
404
+ @click.option("--json", "json_output", is_flag=True, help="Print machine-readable JSON.")
405
+ def jobs_command(run_dir_or_id: str, run_root: Path, json_output: bool) -> None:
406
+ """List background shell jobs for a run."""
407
+ run_dir = _resolve_run_dir(run_dir_or_id, run_root)
408
+ payload = {"run_dir": str(run_dir), "jobs": list_job_artifacts(run_dir)}
409
+ if json_output:
410
+ click.echo(json.dumps(payload, ensure_ascii=False, sort_keys=True))
411
+ return
412
+ for job in payload["jobs"]:
413
+ click.echo(
414
+ f"{job.get('job_id', '')} {job.get('status', '')} "
415
+ f"exit={job.get('exit_code', '')} duration={float(job.get('duration_s') or 0):.3f}s"
416
+ )
417
+
418
+
419
+ @main.group("job")
420
+ def job_group() -> None:
421
+ """Inspect or control one background shell job."""
422
+
423
+
424
+ @job_group.command("status")
425
+ @click.argument("job_id", type=str)
426
+ @click.option("--run", "run_dir_or_id", type=str, required=True)
427
+ @click.option("--run-root", type=click.Path(path_type=Path), default=Path("runs"), show_default=True)
428
+ @click.option("--json", "json_output", is_flag=True, help="Print machine-readable JSON.")
429
+ def job_status_command(job_id: str, run_dir_or_id: str, run_root: Path, json_output: bool) -> None:
430
+ """Show one background job status."""
431
+ run_dir = _resolve_run_dir(run_dir_or_id, run_root)
432
+ payload = get_job_artifact(run_dir, job_id)
433
+ if json_output:
434
+ click.echo(json.dumps(payload, ensure_ascii=False, sort_keys=True))
435
+ return
436
+ click.echo(f"job_id: {payload.get('job_id', '')}")
437
+ click.echo(f"status: {payload.get('status', '')}")
438
+ click.echo(f"exit_code: {payload.get('exit_code', '')}")
439
+ click.echo(f"duration_s: {payload.get('duration_s', '')}")
440
+ click.echo(f"stdout_bytes: {payload.get('stdout_bytes', 0)}")
441
+ click.echo(f"stderr_bytes: {payload.get('stderr_bytes', 0)}")
442
+
443
+
444
+ @job_group.command("logs")
445
+ @click.argument("job_id", type=str)
446
+ @click.option("--run", "run_dir_or_id", type=str, required=True)
447
+ @click.option("--run-root", type=click.Path(path_type=Path), default=Path("runs"), show_default=True)
448
+ @click.option("--stream", "stream_name", type=click.Choice(["stdout", "stderr"]), default="stdout", show_default=True)
449
+ @click.option("--tail-bytes", type=int, default=None)
450
+ @click.option("--offset", type=int, default=None)
451
+ @click.option("--follow", is_flag=True)
452
+ @click.option("--json", "json_output", is_flag=True, help="Print machine-readable JSON.")
453
+ def job_logs_command(
454
+ job_id: str,
455
+ run_dir_or_id: str,
456
+ run_root: Path,
457
+ stream_name: str,
458
+ tail_bytes: int | None,
459
+ offset: int | None,
460
+ follow: bool,
461
+ json_output: bool,
462
+ ) -> None:
463
+ """Read stdout or stderr for one background job."""
464
+ run_dir = _resolve_run_dir(run_dir_or_id, run_root)
465
+ next_offset = offset
466
+ while True:
467
+ payload = read_job_log_text(
468
+ run_dir,
469
+ job_id,
470
+ stream=stream_name, # type: ignore[arg-type]
471
+ tail_bytes=tail_bytes if next_offset is None else None,
472
+ offset=next_offset,
473
+ )
474
+ if json_output:
475
+ click.echo(json.dumps(payload, ensure_ascii=False, sort_keys=True))
476
+ elif payload.get("content"):
477
+ click.echo(payload["content"], nl=False)
478
+ next_offset = int(payload.get("next_offset") or 0)
479
+ if not follow:
480
+ break
481
+ time.sleep(0.5)
482
+
483
+
484
+ @job_group.command("cancel")
485
+ @click.argument("job_id", type=str)
486
+ @click.option("--run", "run_dir_or_id", type=str, required=True)
487
+ @click.option("--run-root", type=click.Path(path_type=Path), default=Path("runs"), show_default=True)
488
+ @click.option("--json", "json_output", is_flag=True, help="Print machine-readable JSON.")
489
+ def job_cancel_command(job_id: str, run_dir_or_id: str, run_root: Path, json_output: bool) -> None:
490
+ """Request cancellation for one background job."""
491
+ run_dir = _resolve_run_dir(run_dir_or_id, run_root)
492
+ payload = request_job_cancel(run_dir, job_id)
493
+ if json_output:
494
+ click.echo(json.dumps(payload, ensure_ascii=False, sort_keys=True))
495
+ else:
496
+ click.echo(f"cancel_requested: {payload['job_id']}")
497
+
498
+
499
+ @main.command()
500
+ @click.argument("run_dir_or_id", type=str)
501
+ @click.option("--run-root", type=click.Path(path_type=Path), default=Path("runs"), show_default=True)
502
+ @click.option("--file", "file_path", type=str, default=None, help="Show one proposed file's snapshot content.")
503
+ @click.option("--json", "json_output", is_flag=True, help="Print machine-readable JSON.")
504
+ def proposal(run_dir_or_id: str, run_root: Path, file_path: str | None, json_output: bool) -> None:
505
+ """Inspect a run's proposal snapshot."""
506
+ run_dir = _resolve_run_dir(run_dir_or_id, run_root)
507
+ proposal_path = run_dir / "proposal.json"
508
+ if not proposal_path.exists():
509
+ raise click.ClickException(f"proposal.json not found: {proposal_path}")
510
+ payload = json.loads(proposal_path.read_text(encoding="utf-8"))
511
+ if not isinstance(payload, dict):
512
+ raise click.ClickException("proposal.json must contain an object")
513
+ if file_path is not None:
514
+ file_payload = _proposal_file_payload(run_dir, payload, file_path)
515
+ if json_output:
516
+ click.echo(json.dumps(file_payload, ensure_ascii=False, sort_keys=True))
517
+ else:
518
+ click.echo(file_payload["content"])
519
+ return
520
+ if json_output:
521
+ click.echo(json.dumps(payload, ensure_ascii=False, sort_keys=True))
522
+ return
523
+ click.echo(f"run_id: {payload.get('run_id', '')}")
524
+ click.echo(f"mode: {payload.get('mode', '')}")
525
+ click.echo(f"diff: {payload.get('diff_path', '')} ({payload.get('diff_bytes', 0)} bytes)")
526
+ files = payload.get("files") if isinstance(payload.get("files"), list) else []
527
+ for file in files:
528
+ if isinstance(file, dict):
529
+ click.echo(
530
+ f"{file.get('change_kind', file.get('kind', '?')):>9} "
531
+ f"{file.get('size', 0):>8} {file.get('path', '')}"
532
+ )
533
+
534
+
535
+ @main.command("validate")
536
+ @click.argument("run_dir_or_id", type=str)
537
+ @click.option("--run-root", type=click.Path(path_type=Path), default=Path("runs"), show_default=True)
538
+ @click.option("--json", "json_output", is_flag=True, help="Print machine-readable JSON.")
539
+ def validate(run_dir_or_id: str, run_root: Path, json_output: bool) -> None:
540
+ """Validate a run directory's public contract artifacts."""
541
+ run_dir = _resolve_run_dir(run_dir_or_id, run_root)
542
+ issues = validate_run_dir(run_dir)
543
+ payload = {
544
+ "run_dir": str(run_dir),
545
+ "ok": not issues,
546
+ "issues": [issue.__dict__ for issue in issues],
547
+ }
548
+ if json_output:
549
+ click.echo(json.dumps(payload, ensure_ascii=False, sort_keys=True))
550
+ elif issues:
551
+ for issue in issues:
552
+ click.echo(f"{issue.path}: {issue.message}")
553
+ else:
554
+ click.echo("ok")
555
+ if issues:
556
+ raise click.ClickException("run directory validation failed")
557
+
558
+
559
+ @main.group("package")
560
+ def package_group() -> None:
561
+ """Export, approve, and apply proposal packages."""
562
+
563
+
564
+ @package_group.command("export")
565
+ @click.argument("run_dir_or_id", type=str)
566
+ @click.option("--run-root", type=click.Path(path_type=Path), default=Path("runs"), show_default=True)
567
+ @click.option("--output", type=click.Path(path_type=Path), required=True)
568
+ @click.option("--json", "json_output", is_flag=True, help="Print machine-readable JSON.")
569
+ def package_export(run_dir_or_id: str, run_root: Path, output: Path, json_output: bool) -> None:
570
+ """Export a run directory as a deterministic proposal tar package."""
571
+ run_dir = _resolve_run_dir(run_dir_or_id, run_root)
572
+ try:
573
+ payload = export_package(run_dir, output)
574
+ append_event_to_run(
575
+ run_dir,
576
+ "proposal.package.exported",
577
+ data={"package_hash": payload["package_hash"], "package_path": str(output)},
578
+ )
579
+ except Exception as exc:
580
+ raise click.ClickException(str(exc)) from exc
581
+ if json_output:
582
+ click.echo(json.dumps(payload, ensure_ascii=False, sort_keys=True))
583
+ else:
584
+ click.echo(f"package: {output}")
585
+ click.echo(f"package_hash: {payload['package_hash']}")
586
+
587
+
588
+ @package_group.command("verify")
589
+ @click.argument("package_or_run_dir", type=str)
590
+ @click.option("--run-root", type=click.Path(path_type=Path), default=Path("runs"), show_default=True)
591
+ @click.option("--json", "json_output", is_flag=True, help="Print machine-readable JSON.")
592
+ def package_verify(package_or_run_dir: str, run_root: Path, json_output: bool) -> None:
593
+ """Verify proposal package hashes and required files."""
594
+ source = _resolve_package_source(package_or_run_dir, run_root)
595
+ result = verify_package(source)
596
+ payload = {
597
+ "ok": result.ok,
598
+ "issues": list(result.issues),
599
+ "source_kind": result.source_kind,
600
+ "package": result.package,
601
+ }
602
+ if json_output:
603
+ click.echo(json.dumps(payload, ensure_ascii=False, sort_keys=True))
604
+ elif result.ok:
605
+ click.echo("ok")
606
+ click.echo(f"package_hash: {result.package.get('package_hash', '')}")
607
+ else:
608
+ for issue in result.issues:
609
+ click.echo(issue)
610
+ if not result.ok:
611
+ raise click.ClickException("package verification failed")
612
+
613
+
614
+ @package_group.command("inspect")
615
+ @click.argument("package_or_run_dir", type=str)
616
+ @click.option("--run-root", type=click.Path(path_type=Path), default=Path("runs"), show_default=True)
617
+ @click.option("--json", "json_output", is_flag=True, help="Print machine-readable JSON.")
618
+ def package_inspect(package_or_run_dir: str, run_root: Path, json_output: bool) -> None:
619
+ """Inspect a proposal package summary."""
620
+ source = _resolve_package_source(package_or_run_dir, run_root)
621
+ payload = inspect_package(source)
622
+ if json_output:
623
+ click.echo(json.dumps(payload, ensure_ascii=False, sort_keys=True))
624
+ return
625
+ click.echo(f"ok: {payload['ok']}")
626
+ click.echo(f"package_hash: {payload.get('package', {}).get('package_hash', '')}")
627
+ click.echo(f"changed_paths: {', '.join(map(str, payload.get('proposal', {}).get('changed_paths', [])))}")
628
+
629
+
630
+ @package_group.command("import")
631
+ @click.argument("package_or_run_dir", type=str)
632
+ @click.option("--run-root", type=click.Path(path_type=Path), default=Path("runs"), show_default=True)
633
+ @click.option("--output", type=click.Path(path_type=Path), required=True)
634
+ @click.option("--json", "json_output", is_flag=True, help="Print machine-readable JSON.")
635
+ def package_import(package_or_run_dir: str, run_root: Path, output: Path, json_output: bool) -> None:
636
+ """Import a proposal package into a verified staging directory."""
637
+ source = _resolve_package_source(package_or_run_dir, run_root)
638
+ try:
639
+ payload = import_package(source, output)
640
+ except Exception as exc:
641
+ raise click.ClickException(str(exc)) from exc
642
+ if json_output:
643
+ click.echo(json.dumps(payload, ensure_ascii=False, sort_keys=True))
644
+ else:
645
+ click.echo(f"imported: {payload['output']}")
646
+ click.echo(f"package_hash: {payload['package_hash']}")
647
+
648
+
649
+ @package_group.command("approve")
650
+ @click.argument("package_or_run_dir", type=str)
651
+ @click.option("--run-root", type=click.Path(path_type=Path), default=Path("runs"), show_default=True)
652
+ @click.option("--approver", type=str, required=True)
653
+ @click.option("--path", "approved_path", multiple=True, help="Approve one changed workspace path. Repeatable.")
654
+ @click.option("--note", type=str, default="")
655
+ @click.option("--output", type=click.Path(path_type=Path), default=None)
656
+ @click.option("--json", "json_output", is_flag=True, help="Print machine-readable JSON.")
657
+ def package_approve(
658
+ package_or_run_dir: str,
659
+ run_root: Path,
660
+ approver: str,
661
+ approved_path: tuple[str, ...],
662
+ note: str,
663
+ output: Path | None,
664
+ json_output: bool,
665
+ ) -> None:
666
+ """Create an approval record for a package."""
667
+ source = _resolve_package_source(package_or_run_dir, run_root)
668
+ try:
669
+ approval = create_approval(
670
+ source,
671
+ approver_id=approver,
672
+ approved_paths=approved_path or None,
673
+ note=note,
674
+ )
675
+ output_path = output or (_source_run_dir(source) / "approval.json" if source.is_dir() else Path("approval.json"))
676
+ write_approval(output_path, approval)
677
+ _append_package_event_if_run_dir(
678
+ source,
679
+ "proposal.approved",
680
+ {"approval_hash": approval["approval_hash"], "package_hash": approval["package_hash"]},
681
+ )
682
+ except Exception as exc:
683
+ raise click.ClickException(str(exc)) from exc
684
+ if json_output:
685
+ click.echo(json.dumps(approval, ensure_ascii=False, sort_keys=True))
686
+ else:
687
+ click.echo(f"approval: {output_path}")
688
+ click.echo(f"approval_hash: {approval['approval_hash']}")
689
+
690
+
691
+ @package_group.command("reject")
692
+ @click.argument("package_or_run_dir", type=str)
693
+ @click.option("--run-root", type=click.Path(path_type=Path), default=Path("runs"), show_default=True)
694
+ @click.option("--approver", type=str, required=True)
695
+ @click.option("--reason", type=str, required=True)
696
+ @click.option("--output", type=click.Path(path_type=Path), default=None)
697
+ @click.option("--json", "json_output", is_flag=True, help="Print machine-readable JSON.")
698
+ def package_reject(
699
+ package_or_run_dir: str,
700
+ run_root: Path,
701
+ approver: str,
702
+ reason: str,
703
+ output: Path | None,
704
+ json_output: bool,
705
+ ) -> None:
706
+ """Create a rejection record for a package."""
707
+ source = _resolve_package_source(package_or_run_dir, run_root)
708
+ try:
709
+ approval = create_approval(source, approver_id=approver, decision="rejected", note=reason)
710
+ output_path = output or (_source_run_dir(source) / "approval.json" if source.is_dir() else Path("approval.json"))
711
+ write_approval(output_path, approval)
712
+ _append_package_event_if_run_dir(
713
+ source,
714
+ "proposal.rejected",
715
+ {"approval_hash": approval["approval_hash"], "package_hash": approval["package_hash"]},
716
+ )
717
+ except Exception as exc:
718
+ raise click.ClickException(str(exc)) from exc
719
+ if json_output:
720
+ click.echo(json.dumps(approval, ensure_ascii=False, sort_keys=True))
721
+ else:
722
+ click.echo(f"approval: {output_path}")
723
+ click.echo(f"approval_hash: {approval['approval_hash']}")
724
+
725
+
726
+ @package_group.command("apply")
727
+ @click.argument("package_or_run_dir", type=str)
728
+ @click.option("--run-root", type=click.Path(path_type=Path), default=Path("runs"), show_default=True)
729
+ @click.option("--approval", "approval_path", type=click.Path(path_type=Path), required=True)
730
+ @click.option("--target", type=click.Path(path_type=Path), required=True)
731
+ @click.option("--dry-run", is_flag=True)
732
+ @click.option("--output", type=click.Path(path_type=Path), default=None)
733
+ @click.option("--json", "json_output", is_flag=True, help="Print machine-readable JSON.")
734
+ def package_apply(
735
+ package_or_run_dir: str,
736
+ run_root: Path,
737
+ approval_path: Path,
738
+ target: Path,
739
+ dry_run: bool,
740
+ output: Path | None,
741
+ json_output: bool,
742
+ ) -> None:
743
+ """Apply an approved package to a local reference target."""
744
+ source = _resolve_package_source(package_or_run_dir, run_root)
745
+ try:
746
+ result = apply_package(source, approval=approval_path, target=target, dry_run=dry_run)
747
+ output_path = output or (_source_run_dir(source) / "apply-result.json" if source.is_dir() else Path("apply-result.json"))
748
+ write_apply_result(output_path, result)
749
+ event_type = "proposal.conflict" if result.status == "conflict" else "proposal.applied"
750
+ _append_package_event_if_run_dir(
751
+ source,
752
+ event_type,
753
+ {
754
+ "status": result.status,
755
+ "approval_hash": result.approval_hash,
756
+ "package_hash": result.package_hash,
757
+ "applied_paths": list(result.applied_paths),
758
+ "conflicts": [conflict.to_json() for conflict in result.conflicts],
759
+ },
760
+ level="warning" if result.status == "conflict" else "info",
761
+ )
762
+ except Exception as exc:
763
+ raise click.ClickException(str(exc)) from exc
764
+ payload = result.to_json()
765
+ if json_output:
766
+ click.echo(json.dumps(payload, ensure_ascii=False, sort_keys=True))
767
+ else:
768
+ click.echo(f"status: {payload['status']}")
769
+ click.echo(f"apply_result: {output_path}")
770
+
771
+
772
+ @main.group()
773
+ def backend() -> None:
774
+ """Run the reference Monoid backend."""
775
+
776
+
777
+ @backend.command("serve")
778
+ @click.option("--host", type=str, default="127.0.0.1", show_default=True)
779
+ @click.option("--port", type=int, default=8765, show_default=True)
780
+ @click.option("--run-root", type=click.Path(path_type=Path), default=Path("runs"), show_default=True)
781
+ @click.option(
782
+ "--workspace-root",
783
+ type=click.Path(path_type=Path),
784
+ multiple=True,
785
+ required=True,
786
+ help="Allowed workspace root. Repeat for multiple roots.",
787
+ )
788
+ @click.option(
789
+ "--apply-root",
790
+ type=click.Path(path_type=Path),
791
+ multiple=True,
792
+ help="Allowed local reference apply root. Repeat for multiple roots.",
793
+ )
794
+ @click.option("--llm-gateway-url", type=str, required=True, help="Internal LLM gateway URL.")
795
+ @click.option("--web-gateway-url", type=str, default=None, help="Internal WebGateway base URL.")
796
+ @click.option(
797
+ "--admin-token-env",
798
+ type=str,
799
+ default="MONOID_BACKEND_ADMIN_TOKEN",
800
+ show_default=True,
801
+ help="Environment variable containing the backend admin token.",
802
+ )
803
+ @click.option(
804
+ "--token-secret-env",
805
+ type=str,
806
+ default="MONOID_BACKEND_TOKEN_SECRET",
807
+ show_default=True,
808
+ help="Environment variable containing a 32+ byte HMAC signing secret.",
809
+ )
810
+ @click.option(
811
+ "--ephemeral-token-secret",
812
+ is_flag=True,
813
+ help="Use an in-memory signing secret for local development.",
814
+ )
815
+ def backend_serve(
816
+ *,
817
+ host: str,
818
+ port: int,
819
+ run_root: Path,
820
+ workspace_root: tuple[Path, ...],
821
+ apply_root: tuple[Path, ...],
822
+ llm_gateway_url: str,
823
+ web_gateway_url: str | None,
824
+ admin_token_env: str,
825
+ token_secret_env: str,
826
+ ephemeral_token_secret: bool,
827
+ ) -> None:
828
+ """Serve token issuance, run submission, status, result, and events APIs."""
829
+ admin_token = getenv(admin_token_env)
830
+ if not admin_token:
831
+ raise click.ClickException(f"{env_name_for_error(admin_token_env)} is required")
832
+ if ephemeral_token_secret:
833
+ token_manager = TokenManager.ephemeral()
834
+ else:
835
+ signing_secret = getenv(token_secret_env)
836
+ if not signing_secret:
837
+ raise click.ClickException(
838
+ f"{env_name_for_error(token_secret_env)} is required, or pass --ephemeral-token-secret for local development"
839
+ )
840
+ token_manager = TokenManager.from_secret(signing_secret)
841
+
842
+ runner_backend = RunnerBackend(
843
+ run_root=run_root,
844
+ token_manager=token_manager,
845
+ allowed_workspace_roots=workspace_root,
846
+ allowed_apply_roots=apply_root,
847
+ llm_gateway_url=llm_gateway_url,
848
+ web_gateway_url=web_gateway_url,
849
+ )
850
+ server = create_backend_server(runner_backend, host=host, port=port, admin_token=admin_token)
851
+ click.echo(f"Monoid backend listening on http://{host}:{port}")
852
+ click.echo(f"allowed workspace roots: {', '.join(str(path.resolve()) for path in workspace_root)}")
853
+ if apply_root:
854
+ click.echo(f"allowed apply roots: {', '.join(str(path.resolve()) for path in apply_root)}")
855
+ try:
856
+ server.serve_forever()
857
+ except KeyboardInterrupt:
858
+ click.echo("Monoid backend stopped")
859
+ finally:
860
+ server.server_close()
861
+ runner_backend.shutdown() # stop the shared run loop + watchdog
862
+
863
+
864
+ @main.group("llm-gateway")
865
+ def llm_gateway() -> None:
866
+ """Run the standalone LLM gateway backend."""
867
+
868
+
869
+ @llm_gateway.command("serve")
870
+ @click.option("--host", type=str, default="127.0.0.1", show_default=True)
871
+ @click.option("--port", type=int, default=8080, show_default=True)
872
+ @click.option(
873
+ "--admin-token-env",
874
+ type=str,
875
+ default="MONOID_LLM_GATEWAY_ADMIN_TOKEN",
876
+ show_default=True,
877
+ help="Environment variable containing the LLM gateway admin token.",
878
+ )
879
+ @click.option(
880
+ "--token-secret-env",
881
+ type=str,
882
+ default="MONOID_BACKEND_TOKEN_SECRET",
883
+ show_default=True,
884
+ help="Environment variable containing the shared 32+ byte HMAC signing secret.",
885
+ )
886
+ @click.option(
887
+ "--ephemeral-token-secret",
888
+ is_flag=True,
889
+ help="Use an in-memory signing secret for local development.",
890
+ )
891
+ @click.option(
892
+ "--provider",
893
+ type=click.Choice(["openai", "fake"]),
894
+ default="openai",
895
+ show_default=True,
896
+ help="openai = direct OpenAIModelAdapter (needs OPENAI_API_KEY); "
897
+ "fake = key-less offline echo model for local development.",
898
+ )
899
+ def llm_gateway_serve(
900
+ *,
901
+ host: str,
902
+ port: int,
903
+ admin_token_env: str,
904
+ token_secret_env: str,
905
+ ephemeral_token_secret: bool,
906
+ provider: str,
907
+ ) -> None:
908
+ """Serve the internal LLM turn API consumed by GatewayModelAdapter."""
909
+ admin_token = getenv(admin_token_env)
910
+ if not admin_token:
911
+ raise click.ClickException(f"{env_name_for_error(admin_token_env)} is required")
912
+ if ephemeral_token_secret:
913
+ token_manager = TokenManager.ephemeral()
914
+ else:
915
+ signing_secret = getenv(token_secret_env)
916
+ if not signing_secret:
917
+ raise click.ClickException(
918
+ f"{env_name_for_error(token_secret_env)} is required, or pass --ephemeral-token-secret for local development"
919
+ )
920
+ token_manager = TokenManager.from_secret(signing_secret)
921
+
922
+ provider_factory = offline_provider_factory if provider == "fake" else None
923
+ gateway = LlmGatewayBackend(token_manager=token_manager, provider_adapter_factory=provider_factory)
924
+ server = create_llm_gateway_server(gateway, host=host, port=port, admin_token=admin_token)
925
+ click.echo(f"LLM gateway listening on http://{host}:{port}")
926
+ click.echo("turn endpoint: /internal/llm/turns")
927
+ try:
928
+ server.serve_forever()
929
+ except KeyboardInterrupt:
930
+ click.echo("LLM gateway stopped")
931
+ finally:
932
+ server.server_close()
933
+
934
+
935
+ @main.group("web-gateway")
936
+ def web_gateway() -> None:
937
+ """Run the standalone reference WebGateway backend."""
938
+
939
+
940
+ @web_gateway.command("serve")
941
+ @click.option("--host", type=str, default="127.0.0.1", show_default=True)
942
+ @click.option("--port", type=int, default=8090, show_default=True)
943
+ @click.option(
944
+ "--provider",
945
+ type=click.Choice(["fake", "brave-http"]),
946
+ default="fake",
947
+ show_default=True,
948
+ help="Web provider implementation. brave-http uses Brave for search and direct HTTP for fetch.",
949
+ )
950
+ @click.option(
951
+ "--context-provider",
952
+ type=click.Choice(["none", "search-fetch", "brave-llm"]),
953
+ default="none",
954
+ show_default=True,
955
+ help="Optional LLM context provider for /internal/web/context.",
956
+ )
957
+ @click.option(
958
+ "--brave-api-key-env",
959
+ type=str,
960
+ default="BRAVE_SEARCH_API_KEY",
961
+ show_default=True,
962
+ help="Environment variable containing the Brave Search API key.",
963
+ )
964
+ @click.option("--brave-country", type=str, default="US", show_default=True)
965
+ @click.option("--brave-search-lang", type=str, default="en", show_default=True)
966
+ @click.option(
967
+ "--brave-llm-context-endpoint",
968
+ type=str,
969
+ default="https://api.search.brave.com/res/v1/llm/context",
970
+ show_default=True,
971
+ )
972
+ @click.option("--provider-timeout-s", type=int, default=10, show_default=True)
973
+ @click.option("--fetch-timeout-s", type=int, default=20, show_default=True)
974
+ @click.option("--fetch-max-raw-bytes", type=int, default=2_000_000, show_default=True)
975
+ @click.option("--fetch-user-agent", type=str, default=None)
976
+ @click.option(
977
+ "--admin-token-env",
978
+ type=str,
979
+ default="MONOID_WEB_GATEWAY_ADMIN_TOKEN",
980
+ show_default=True,
981
+ help="Environment variable containing the WebGateway admin token.",
982
+ )
983
+ @click.option(
984
+ "--token-secret-env",
985
+ type=str,
986
+ default="MONOID_BACKEND_TOKEN_SECRET",
987
+ show_default=True,
988
+ help="Environment variable containing the shared 32+ byte HMAC signing secret.",
989
+ )
990
+ @click.option(
991
+ "--ephemeral-token-secret",
992
+ is_flag=True,
993
+ help="Use an in-memory signing secret for local development.",
994
+ )
995
+ def web_gateway_serve(
996
+ *,
997
+ host: str,
998
+ port: int,
999
+ provider: str,
1000
+ context_provider: str,
1001
+ brave_api_key_env: str,
1002
+ brave_country: str,
1003
+ brave_search_lang: str,
1004
+ brave_llm_context_endpoint: str,
1005
+ provider_timeout_s: int,
1006
+ fetch_timeout_s: int,
1007
+ fetch_max_raw_bytes: int,
1008
+ fetch_user_agent: str | None,
1009
+ admin_token_env: str,
1010
+ token_secret_env: str,
1011
+ ephemeral_token_secret: bool,
1012
+ ) -> None:
1013
+ """Serve the internal web.search/web.fetch/web.context API consumed by WebGatewayClient."""
1014
+ admin_token = getenv(admin_token_env)
1015
+ if not admin_token:
1016
+ raise click.ClickException(f"{env_name_for_error(admin_token_env)} is required")
1017
+ if ephemeral_token_secret:
1018
+ token_manager = TokenManager.ephemeral()
1019
+ else:
1020
+ signing_secret = getenv(token_secret_env)
1021
+ if not signing_secret:
1022
+ raise click.ClickException(
1023
+ f"{env_name_for_error(token_secret_env)} is required, or pass --ephemeral-token-secret for local development"
1024
+ )
1025
+ token_manager = TokenManager.from_secret(signing_secret)
1026
+
1027
+ try:
1028
+ web_provider = _build_web_provider(
1029
+ provider,
1030
+ context_provider=context_provider,
1031
+ brave_api_key_env=brave_api_key_env,
1032
+ brave_country=brave_country,
1033
+ brave_search_lang=brave_search_lang,
1034
+ brave_llm_context_endpoint=brave_llm_context_endpoint,
1035
+ provider_timeout_s=provider_timeout_s,
1036
+ fetch_timeout_s=fetch_timeout_s,
1037
+ fetch_max_raw_bytes=fetch_max_raw_bytes,
1038
+ fetch_user_agent=fetch_user_agent,
1039
+ )
1040
+ except Exception as exc:
1041
+ raise click.ClickException(str(exc)) from exc
1042
+
1043
+ gateway = WebGatewayBackend(token_manager=token_manager, provider=web_provider)
1044
+ server = create_web_gateway_server(gateway, host=host, port=port, admin_token=admin_token)
1045
+ click.echo(f"WebGateway listening on http://{host}:{port}")
1046
+ click.echo(f"provider: {provider}")
1047
+ click.echo(f"context provider: {context_provider}")
1048
+ click.echo("search endpoint: /internal/web/search")
1049
+ click.echo("fetch endpoint: /internal/web/fetch")
1050
+ click.echo("context endpoint: /internal/web/context")
1051
+ try:
1052
+ server.serve_forever()
1053
+ except KeyboardInterrupt:
1054
+ click.echo("WebGateway stopped")
1055
+ finally:
1056
+ server.server_close()
1057
+
1058
+
1059
+ def _human_echo(message: str, *, stream_json: bool) -> None:
1060
+ click.echo(message, err=stream_json)
1061
+
1062
+
1063
+ def _build_web_provider(
1064
+ provider: str,
1065
+ *,
1066
+ context_provider: str,
1067
+ brave_api_key_env: str,
1068
+ brave_country: str,
1069
+ brave_search_lang: str,
1070
+ brave_llm_context_endpoint: str,
1071
+ provider_timeout_s: int,
1072
+ fetch_timeout_s: int,
1073
+ fetch_max_raw_bytes: int,
1074
+ fetch_user_agent: str | None,
1075
+ ):
1076
+ if provider == "fake":
1077
+ return FakeWebProvider()
1078
+ if provider == "brave-http":
1079
+ search_provider = BraveSearchProvider.from_env(
1080
+ api_key_env=brave_api_key_env,
1081
+ country=brave_country,
1082
+ search_lang=brave_search_lang,
1083
+ timeout_s=provider_timeout_s,
1084
+ )
1085
+ fetch_provider = HttpFetchProvider(
1086
+ timeout_s=fetch_timeout_s,
1087
+ max_raw_bytes=fetch_max_raw_bytes,
1088
+ user_agent=fetch_user_agent or "monoid-agent-kernel-webgateway/0.13",
1089
+ )
1090
+ selected_context_provider = None
1091
+ if context_provider == "search-fetch":
1092
+ selected_context_provider = SearchFetchContextProvider(
1093
+ search_provider=search_provider,
1094
+ fetch_provider=fetch_provider,
1095
+ )
1096
+ elif context_provider == "brave-llm":
1097
+ selected_context_provider = BraveLlmContextProvider.from_env(
1098
+ api_key_env=brave_api_key_env,
1099
+ endpoint=brave_llm_context_endpoint,
1100
+ country=brave_country,
1101
+ search_lang=brave_search_lang,
1102
+ timeout_s=provider_timeout_s,
1103
+ )
1104
+ return CompositeWebProvider(
1105
+ search_provider=search_provider,
1106
+ fetch_provider=fetch_provider,
1107
+ context_provider=selected_context_provider,
1108
+ )
1109
+ raise ValueError(f"unsupported web provider: {provider}")
1110
+
1111
+
1112
+ def _model_adapter(
1113
+ config: ModelConfig,
1114
+ *,
1115
+ llm_gateway_url: str | None,
1116
+ llm_gateway_token_env: str,
1117
+ llm_gateway_token_file: Path | None,
1118
+ allow_direct_provider_api: bool,
1119
+ ) -> ModelAdapter:
1120
+ if config.provider == "gateway":
1121
+ return GatewayModelAdapter(
1122
+ config,
1123
+ gateway_url=llm_gateway_url,
1124
+ token_env=llm_gateway_token_env,
1125
+ token_file=llm_gateway_token_file,
1126
+ )
1127
+ if config.provider == "openai":
1128
+ if not allow_direct_provider_api:
1129
+ raise click.ClickException(
1130
+ "OpenAI runtime configs require --allow-direct-provider-api; "
1131
+ "container runs should use a gateway runtime config"
1132
+ )
1133
+ return OpenAIModelAdapter(config, allow_direct_provider_api=True)
1134
+ raise click.ClickException(f"unsupported model provider: {config.provider}")
1135
+
1136
+
1137
+ def _load_agent_runtime_config(
1138
+ runtime_config_file: Path | None,
1139
+ agent_definition_file: Path | None,
1140
+ ) -> AgentRuntimeConfig:
1141
+ if runtime_config_file is not None and agent_definition_file is not None:
1142
+ raise click.ClickException("--runtime-config-file and --agent-definition-file are mutually exclusive")
1143
+ if runtime_config_file is None and agent_definition_file is None:
1144
+ raise click.ClickException("--runtime-config-file or --agent-definition-file is required")
1145
+ config_file = runtime_config_file or agent_definition_file
1146
+ assert config_file is not None
1147
+ try:
1148
+ payload = json.loads(config_file.read_text(encoding="utf-8"))
1149
+ except json.JSONDecodeError as exc:
1150
+ raise click.ClickException(f"invalid agent config JSON: {exc.msg}") from exc
1151
+ try:
1152
+ if runtime_config_file is not None:
1153
+ return AgentRuntimeConfig.from_json(payload)
1154
+ return AgentRuntimeConfig.from_definition(AgentDefinition.from_json(payload))
1155
+ except Exception as exc:
1156
+ raise click.ClickException(f"failed to load agent runtime config: {exc}") from exc
1157
+
1158
+
1159
+ def _runtime_config_uses_web(config: AgentRuntimeConfig) -> bool:
1160
+ return any(binding.ref.tool_id.startswith("web.") for binding in config.tools)
1161
+
1162
+
1163
+ def _load_permission_policy(
1164
+ policy_file: Path | None,
1165
+ *,
1166
+ deny_path: tuple[str, ...],
1167
+ redact_path: tuple[str, ...],
1168
+ ) -> PermissionPolicy:
1169
+ policy = PermissionPolicy()
1170
+ if policy_file is not None:
1171
+ try:
1172
+ payload = json.loads(policy_file.read_text(encoding="utf-8"))
1173
+ except json.JSONDecodeError as exc:
1174
+ raise ValueError(f"invalid permission policy JSON: {exc.msg}") from exc
1175
+ policy = PermissionPolicy.from_json(payload)
1176
+ return policy.merged(deny_patterns=deny_path, redact_patterns=redact_path)
1177
+
1178
+
1179
+ def _resolve_events_path(run_dir_or_id: str, run_root: Path) -> Path:
1180
+ return _resolve_run_dir(run_dir_or_id, run_root) / "events.jsonl"
1181
+
1182
+
1183
+ def _resolve_run_dir(run_dir_or_id: str, run_root: Path) -> Path:
1184
+ candidate = Path(run_dir_or_id)
1185
+ return candidate if candidate.exists() else run_root / run_dir_or_id
1186
+
1187
+
1188
+ def _resolve_package_source(package_or_run_dir: str, run_root: Path) -> Path:
1189
+ candidate = Path(package_or_run_dir)
1190
+ return candidate if candidate.exists() else run_root / package_or_run_dir
1191
+
1192
+
1193
+ def _source_run_dir(source: Path) -> Path:
1194
+ return source.resolve()
1195
+
1196
+
1197
+ def _append_package_event_if_run_dir(
1198
+ source: Path,
1199
+ event_type: str,
1200
+ data: dict[str, Any],
1201
+ *,
1202
+ level: str = "info",
1203
+ ) -> None:
1204
+ if source.is_dir():
1205
+ append_event_to_run(source.resolve(), event_type, data=data, level=level)
1206
+
1207
+
1208
+ def _proposal_file_payload(run_dir: Path, proposal: dict[str, Any], file_path: str) -> dict[str, Any]:
1209
+ try:
1210
+ return read_proposal_file_payload(run_dir, proposal, file_path)
1211
+ except ProposalFileError as exc:
1212
+ raise click.ClickException(str(exc)) from exc
1213
+
1214
+
1215
+ def _compact_event_line(line: str) -> str:
1216
+ try:
1217
+ event = json.loads(line)
1218
+ except json.JSONDecodeError:
1219
+ return line.rstrip("\n")
1220
+ # Tool activity goes through the shared narration projection (the same one the Studio feed
1221
+ # uses), so the verb/target extraction lives in one place. Other events keep a generic dump.
1222
+ narration = narrate_event(event)
1223
+ if narration is not None:
1224
+ suffix = f" {narration.action}"
1225
+ if narration.target:
1226
+ suffix += f" {narration.target}"
1227
+ if narration.status == "error":
1228
+ suffix += f" [error: {narration.detail}]" if narration.detail else " [error]"
1229
+ else:
1230
+ data = event.get("data") or {}
1231
+ suffix = ""
1232
+ if "status" in data:
1233
+ suffix = f" status={data['status']}"
1234
+ elif "job_id" in data:
1235
+ suffix = f" job={data['job_id']}"
1236
+ elif "paths" in data:
1237
+ suffix = f" paths={','.join(map(str, data['paths']))}"
1238
+ elif "error" in data and data["error"]:
1239
+ suffix = f" error={data['error']}"
1240
+ return f"{event.get('seq', '?'):>4} {event.get('type', '?')}{suffix}"
1241
+
1242
+
1243
+ if __name__ == "__main__":
1244
+ main()