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.
- monoid_agent_kernel/__init__.py +11 -0
- monoid_agent_kernel/_policy_util.py +40 -0
- monoid_agent_kernel/_proc.py +86 -0
- monoid_agent_kernel/builder.py +351 -0
- monoid_agent_kernel/cli.py +1244 -0
- monoid_agent_kernel/conformance/__init__.py +32 -0
- monoid_agent_kernel/conformance/harness.py +209 -0
- monoid_agent_kernel/conformance/profiles/__init__.py +55 -0
- monoid_agent_kernel/conformance/profiles/_metadata.py +16 -0
- monoid_agent_kernel/conformance/profiles/capability_security.py +216 -0
- monoid_agent_kernel/conformance/profiles/control_plane.py +41 -0
- monoid_agent_kernel/conformance/profiles/durable_runner.py +42 -0
- monoid_agent_kernel/conformance/profiles/message_fabric.py +42 -0
- monoid_agent_kernel/conformance/profiles/minimal_agent.py +13 -0
- monoid_agent_kernel/conformance/profiles/multi_agent.py +112 -0
- monoid_agent_kernel/conformance/profiles/provider_gateway.py +107 -0
- monoid_agent_kernel/conformance/profiles/reference_full.py +135 -0
- monoid_agent_kernel/conformance/profiles/side_effect_tool_agent.py +36 -0
- monoid_agent_kernel/conformance/profiles/tool_agent.py +39 -0
- monoid_agent_kernel/contracts.py +329 -0
- monoid_agent_kernel/core/__init__.py +1 -0
- monoid_agent_kernel/core/_util.py +145 -0
- monoid_agent_kernel/core/agents.py +883 -0
- monoid_agent_kernel/core/cancellation.py +16 -0
- monoid_agent_kernel/core/capability.py +376 -0
- monoid_agent_kernel/core/capability_revocation.py +92 -0
- monoid_agent_kernel/core/checkpoint.py +350 -0
- monoid_agent_kernel/core/content.py +101 -0
- monoid_agent_kernel/core/context.py +67 -0
- monoid_agent_kernel/core/control.py +124 -0
- monoid_agent_kernel/core/control_audit.py +99 -0
- monoid_agent_kernel/core/durable_metadata.py +116 -0
- monoid_agent_kernel/core/event_sequencing.py +164 -0
- monoid_agent_kernel/core/events.py +204 -0
- monoid_agent_kernel/core/external_agent_envelope.py +338 -0
- monoid_agent_kernel/core/frontmatter.py +140 -0
- monoid_agent_kernel/core/inbox.py +110 -0
- monoid_agent_kernel/core/lease_admission.py +57 -0
- monoid_agent_kernel/core/lifecycle.py +440 -0
- monoid_agent_kernel/core/manifest.py +88 -0
- monoid_agent_kernel/core/media.py +393 -0
- monoid_agent_kernel/core/outbox.py +212 -0
- monoid_agent_kernel/core/output_validator.py +103 -0
- monoid_agent_kernel/core/packages.py +742 -0
- monoid_agent_kernel/core/projections.py +213 -0
- monoid_agent_kernel/core/prompt.py +37 -0
- monoid_agent_kernel/core/proposal_file.py +84 -0
- monoid_agent_kernel/core/result.py +131 -0
- monoid_agent_kernel/core/schemas.py +1146 -0
- monoid_agent_kernel/core/scope.py +185 -0
- monoid_agent_kernel/core/side_effect_policy.py +153 -0
- monoid_agent_kernel/core/spec.py +325 -0
- monoid_agent_kernel/core/streaming.py +208 -0
- monoid_agent_kernel/core/subagent_runtime.py +187 -0
- monoid_agent_kernel/core/tool_approval.py +175 -0
- monoid_agent_kernel/core/tool_surface.py +505 -0
- monoid_agent_kernel/core/trace_context.py +76 -0
- monoid_agent_kernel/core/wire_validation.py +156 -0
- monoid_agent_kernel/core/workspace.py +161 -0
- monoid_agent_kernel/core/workspace_index.py +127 -0
- monoid_agent_kernel/env.py +32 -0
- monoid_agent_kernel/errors.py +105 -0
- monoid_agent_kernel/event_loader.py +47 -0
- monoid_agent_kernel/identifiers.py +50 -0
- monoid_agent_kernel/loop.py +4140 -0
- monoid_agent_kernel/loop_phases.py +561 -0
- monoid_agent_kernel/mcp/__init__.py +10 -0
- monoid_agent_kernel/mcp/client.py +227 -0
- monoid_agent_kernel/mcp/provider.py +407 -0
- monoid_agent_kernel/narration.py +92 -0
- monoid_agent_kernel/observability/__init__.py +9 -0
- monoid_agent_kernel/observability/otel.py +264 -0
- monoid_agent_kernel/permissions.py +74 -0
- monoid_agent_kernel/providers/__init__.py +7 -0
- monoid_agent_kernel/providers/_common.py +122 -0
- monoid_agent_kernel/providers/base.py +312 -0
- monoid_agent_kernel/providers/fake.py +76 -0
- monoid_agent_kernel/providers/gateway.py +474 -0
- monoid_agent_kernel/providers/openai.py +487 -0
- monoid_agent_kernel/public_view.py +173 -0
- monoid_agent_kernel/py.typed +0 -0
- monoid_agent_kernel/recorder.py +421 -0
- monoid_agent_kernel/reference/__init__.py +15 -0
- monoid_agent_kernel/reference/_shared/__init__.py +4 -0
- monoid_agent_kernel/reference/_shared/http_util.py +111 -0
- monoid_agent_kernel/reference/_shared/tokens.py +314 -0
- monoid_agent_kernel/reference/backend/__init__.py +21 -0
- monoid_agent_kernel/reference/backend/commands.py +375 -0
- monoid_agent_kernel/reference/backend/http.py +439 -0
- monoid_agent_kernel/reference/backend/jobs.py +68 -0
- monoid_agent_kernel/reference/backend/loop_factory.py +253 -0
- monoid_agent_kernel/reference/backend/outbox_dispatch.py +130 -0
- monoid_agent_kernel/reference/backend/ports.py +188 -0
- monoid_agent_kernel/reference/backend/projection.py +286 -0
- monoid_agent_kernel/reference/backend/proposal.py +220 -0
- monoid_agent_kernel/reference/backend/proposal_reader.py +16 -0
- monoid_agent_kernel/reference/backend/recovery.py +253 -0
- monoid_agent_kernel/reference/backend/run_execution.py +152 -0
- monoid_agent_kernel/reference/backend/run_preparation.py +197 -0
- monoid_agent_kernel/reference/backend/run_state.py +243 -0
- monoid_agent_kernel/reference/backend/run_types.py +128 -0
- monoid_agent_kernel/reference/backend/runtime_config.py +147 -0
- monoid_agent_kernel/reference/backend/service.py +1664 -0
- monoid_agent_kernel/reference/backend/session.py +280 -0
- monoid_agent_kernel/reference/backend/session_drive.py +231 -0
- monoid_agent_kernel/reference/capability.py +101 -0
- monoid_agent_kernel/reference/conformance.py +1777 -0
- monoid_agent_kernel/reference/llm_gateway/__init__.py +14 -0
- monoid_agent_kernel/reference/llm_gateway/http.py +225 -0
- monoid_agent_kernel/reference/llm_gateway/providers.py +96 -0
- monoid_agent_kernel/reference/llm_gateway/service.py +404 -0
- monoid_agent_kernel/reference/mcp_gateway/__init__.py +26 -0
- monoid_agent_kernel/reference/mcp_gateway/http.py +187 -0
- monoid_agent_kernel/reference/mcp_gateway/service.py +206 -0
- monoid_agent_kernel/reference/outbox.py +135 -0
- monoid_agent_kernel/reference/stores/__init__.py +20 -0
- monoid_agent_kernel/reference/stores/lease.py +116 -0
- monoid_agent_kernel/reference/stores/sqlite.py +295 -0
- monoid_agent_kernel/reference/studio/README.md +97 -0
- monoid_agent_kernel/reference/studio/__init__.py +21 -0
- monoid_agent_kernel/reference/studio/activity.py +53 -0
- monoid_agent_kernel/reference/studio/cli.py +481 -0
- monoid_agent_kernel/reference/studio/sample-skills/code-review-checklist/SKILL.md +25 -0
- monoid_agent_kernel/reference/studio/sample-skills/code-review-checklist/references/review-checklist.md +8 -0
- monoid_agent_kernel/reference/studio/sample-skills/commit-message/SKILL.md +32 -0
- monoid_agent_kernel/reference/studio/sample-skills/incident-summary/SKILL.md +24 -0
- monoid_agent_kernel/reference/studio/sample-skills/incident-summary/references/incident-template.md +32 -0
- monoid_agent_kernel/reference/studio/sample-skills/release-notes/SKILL.md +24 -0
- monoid_agent_kernel/reference/studio/sample-skills/release-notes/references/release-note-template.md +33 -0
- monoid_agent_kernel/reference/studio/sample-skills/release-notes/scripts/collect_changes.py +51 -0
- monoid_agent_kernel/reference/studio/server.py +1922 -0
- monoid_agent_kernel/reference/studio/web/index.html +1877 -0
- monoid_agent_kernel/reference/studio/web/settings.html +108 -0
- monoid_agent_kernel/reference/studio/web/vendor/katex/auto-render.min.js +1 -0
- monoid_agent_kernel/reference/studio/web/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
- monoid_agent_kernel/reference/studio/web/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
- monoid_agent_kernel/reference/studio/web/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
- monoid_agent_kernel/reference/studio/web/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
- monoid_agent_kernel/reference/studio/web/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
- monoid_agent_kernel/reference/studio/web/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
- monoid_agent_kernel/reference/studio/web/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
- monoid_agent_kernel/reference/studio/web/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
- monoid_agent_kernel/reference/studio/web/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
- monoid_agent_kernel/reference/studio/web/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
- monoid_agent_kernel/reference/studio/web/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
- monoid_agent_kernel/reference/studio/web/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
- monoid_agent_kernel/reference/studio/web/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
- monoid_agent_kernel/reference/studio/web/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
- monoid_agent_kernel/reference/studio/web/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
- monoid_agent_kernel/reference/studio/web/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
- monoid_agent_kernel/reference/studio/web/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
- monoid_agent_kernel/reference/studio/web/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
- monoid_agent_kernel/reference/studio/web/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
- monoid_agent_kernel/reference/studio/web/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
- monoid_agent_kernel/reference/studio/web/vendor/katex/katex.min.css +1 -0
- monoid_agent_kernel/reference/studio/web/vendor/katex/katex.min.js +1 -0
- monoid_agent_kernel/reference/studio/window.py +72 -0
- monoid_agent_kernel/reference/web_gateway/__init__.py +6 -0
- monoid_agent_kernel/reference/web_gateway/http.py +155 -0
- monoid_agent_kernel/reference/web_gateway/providers.py +807 -0
- monoid_agent_kernel/reference/web_gateway/service.py +559 -0
- monoid_agent_kernel/shell.py +722 -0
- monoid_agent_kernel/skills/__init__.py +20 -0
- monoid_agent_kernel/skills/definition.py +82 -0
- monoid_agent_kernel/skills/loader.py +55 -0
- monoid_agent_kernel/skills/provider.py +378 -0
- monoid_agent_kernel/subagent_loader.py +56 -0
- monoid_agent_kernel/tasks.py +1326 -0
- monoid_agent_kernel/tool_loader.py +51 -0
- monoid_agent_kernel/tool_services/__init__.py +8 -0
- monoid_agent_kernel/tool_services/base.py +25 -0
- monoid_agent_kernel/tool_services/jobs.py +47 -0
- monoid_agent_kernel/tool_services/shell.py +255 -0
- monoid_agent_kernel/tool_services/web.py +356 -0
- monoid_agent_kernel/tools/__init__.py +2 -0
- monoid_agent_kernel/tools/base.py +205 -0
- monoid_agent_kernel/tools/builtin.py +958 -0
- monoid_agent_kernel/tools/decorator.py +132 -0
- monoid_agent_kernel/tools/tool_ids.py +62 -0
- monoid_agent_kernel/web.py +171 -0
- monoid_agent_kernel/workspace/__init__.py +2 -0
- monoid_agent_kernel/workspace/local.py +1004 -0
- monoid_agent_kernel/workspace/paths.py +30 -0
- monoid_agent_kernel-0.16.1.dist-info/METADATA +709 -0
- monoid_agent_kernel-0.16.1.dist-info/RECORD +191 -0
- monoid_agent_kernel-0.16.1.dist-info/WHEEL +4 -0
- monoid_agent_kernel-0.16.1.dist-info/entry_points.txt +3 -0
- monoid_agent_kernel-0.16.1.dist-info/licenses/LICENSE +202 -0
- monoid_agent_kernel-0.16.1.dist-info/licenses/NOTICE +8 -0
- native_agent_runner/__init__.py +103 -0
- 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()
|