@openlucaskaka/kagent 0.1.7
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.
- package/README.md +353 -0
- package/npm/bin/kagent-serve.js +6 -0
- package/npm/bin/kagent.js +6 -0
- package/npm/lib/App.js +524 -0
- package/npm/lib/app-state.js +224 -0
- package/npm/lib/approval-choice.js +25 -0
- package/npm/lib/commands.js +59 -0
- package/npm/lib/editor.js +188 -0
- package/npm/lib/ink-runner.js +41 -0
- package/npm/lib/kagent-home.js +39 -0
- package/npm/lib/launcher.js +221 -0
- package/npm/lib/protocol.js +33 -0
- package/npm/lib/provider-setup.js +139 -0
- package/npm/lib/python-runner.js +892 -0
- package/npm/lib/runtime-client.js +390 -0
- package/npm/lib/terminal-input.js +127 -0
- package/npm/lib/terminal-text.js +19 -0
- package/npm/lib/terminal-width.js +14 -0
- package/npm/lib/transcript.js +227 -0
- package/npm/lib/ui-components.js +247 -0
- package/npm/lib/update-manager.js +334 -0
- package/package.json +39 -0
- package/pyproject.toml +55 -0
- package/src/kagent/__init__.py +90 -0
- package/src/kagent/cli/__init__.py +5 -0
- package/src/kagent/cli/__main__.py +6 -0
- package/src/kagent/cli/commands.py +112 -0
- package/src/kagent/cli/conversation.py +127 -0
- package/src/kagent/cli/interactive.py +841 -0
- package/src/kagent/cli/main.py +685 -0
- package/src/kagent/cli/memory.py +460 -0
- package/src/kagent/cli/pending_approval.py +169 -0
- package/src/kagent/cli/provider.py +27 -0
- package/src/kagent/cli/session_commands.py +401 -0
- package/src/kagent/cli/stdio_runtime.py +784 -0
- package/src/kagent/cli/trace.py +67 -0
- package/src/kagent/cli/ui.py +931 -0
- package/src/kagent/core/__init__.py +0 -0
- package/src/kagent/core/agent.py +296 -0
- package/src/kagent/core/faults.py +11 -0
- package/src/kagent/core/invariants.py +47 -0
- package/src/kagent/core/normalization.py +81 -0
- package/src/kagent/core/planning.py +48 -0
- package/src/kagent/core/state.py +73 -0
- package/src/kagent/core/summary.py +64 -0
- package/src/kagent/core/tools.py +196 -0
- package/src/kagent/core/trace.py +57 -0
- package/src/kagent/eval/__init__.py +11 -0
- package/src/kagent/eval/cases.py +229 -0
- package/src/kagent/eval/evaluator.py +216 -0
- package/src/kagent/integrations/__init__.py +3 -0
- package/src/kagent/integrations/audit.py +95 -0
- package/src/kagent/integrations/backends.py +131 -0
- package/src/kagent/integrations/memory.py +301 -0
- package/src/kagent/ops/__init__.py +0 -0
- package/src/kagent/ops/batch.py +156 -0
- package/src/kagent/ops/doctor.py +255 -0
- package/src/kagent/ops/metrics.py +214 -0
- package/src/kagent/ops/release_evidence.py +877 -0
- package/src/kagent/ops/release_manifest.py +142 -0
- package/src/kagent/ops/trace_replay.py +285 -0
- package/src/kagent/providers/__init__.py +7 -0
- package/src/kagent/providers/embeddings.py +187 -0
- package/src/kagent/providers/llm.py +770 -0
- package/src/kagent/py.typed +1 -0
- package/src/kagent/runtime/__init__.py +28 -0
- package/src/kagent/runtime/action_graph.py +543 -0
- package/src/kagent/runtime/agent.py +2089 -0
- package/src/kagent/runtime/approval.py +64 -0
- package/src/kagent/runtime/cancellation.py +64 -0
- package/src/kagent/runtime/checkpoint_state.py +146 -0
- package/src/kagent/runtime/checkpoint_storage.py +270 -0
- package/src/kagent/runtime/context.py +65 -0
- package/src/kagent/runtime/file_transaction.py +195 -0
- package/src/kagent/runtime/hooks.py +74 -0
- package/src/kagent/runtime/metadata.py +116 -0
- package/src/kagent/runtime/patch_checkpoints.py +385 -0
- package/src/kagent/runtime/policy.py +50 -0
- package/src/kagent/runtime/presentation.py +205 -0
- package/src/kagent/runtime/redaction.py +130 -0
- package/src/kagent/runtime/sandbox.py +331 -0
- package/src/kagent/runtime/skills.py +66 -0
- package/src/kagent/runtime/steering.py +56 -0
- package/src/kagent/runtime/steps.py +255 -0
- package/src/kagent/runtime/task_state.py +40 -0
- package/src/kagent/runtime/tools.py +3532 -0
- package/src/kagent/runtime/types.py +240 -0
- package/src/kagent/runtime/workspace.py +597 -0
- package/src/kagent/service/__init__.py +26 -0
- package/src/kagent/service/__main__.py +6 -0
- package/src/kagent/service/active_runs.py +178 -0
- package/src/kagent/service/cli.py +737 -0
- package/src/kagent/service/contract.py +2571 -0
- package/src/kagent/service/errors.py +71 -0
- package/src/kagent/service/idempotency.py +584 -0
- package/src/kagent/service/router.py +884 -0
- package/src/kagent/service/run.py +150 -0
- package/src/kagent/service/runtime.py +1915 -0
- package/src/kagent/service/runtime_approval.py +20 -0
- package/src/kagent/service/runtime_cancel.py +192 -0
- package/src/kagent/service/runtime_lifecycle.py +229 -0
- package/src/kagent/service/runtime_metadata.py +21 -0
- package/src/kagent/service/runtime_policy.py +115 -0
- package/src/kagent/service/runtime_recovery.py +573 -0
- package/src/kagent/service/runtime_resume.py +382 -0
- package/src/kagent/service/runtime_resume_claim.py +116 -0
- package/src/kagent/service/runtime_run.py +350 -0
- package/src/kagent/service/runtime_status.py +2007 -0
- package/src/kagent/service/safety.py +139 -0
- package/src/kagent/service/server.py +114 -0
- package/src/kagent/service/status.py +233 -0
- package/src/kagent/service/trace_store.py +322 -0
- package/src/kagent/service/transport.py +24 -0
- package/src/kagent/utils/__init__.py +0 -0
- package/src/kagent/utils/config_validation.py +21 -0
- package/src/kagent/utils/json_output.py +23 -0
- package/src/kagent/utils/paths.py +473 -0
|
@@ -0,0 +1,737 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
# ruff: noqa: E402, I001
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import signal
|
|
8
|
+
import socket
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
import warnings
|
|
12
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
13
|
+
from typing import Any, List, Mapping, Optional
|
|
14
|
+
from urllib.parse import urlparse
|
|
15
|
+
from uuid import uuid4
|
|
16
|
+
|
|
17
|
+
warnings.filterwarnings("ignore")
|
|
18
|
+
|
|
19
|
+
from kagent.service import (
|
|
20
|
+
contract as service_contract,
|
|
21
|
+
)
|
|
22
|
+
from kagent.service import (
|
|
23
|
+
errors as service_errors,
|
|
24
|
+
)
|
|
25
|
+
from kagent.service import (
|
|
26
|
+
router as service_router,
|
|
27
|
+
)
|
|
28
|
+
from kagent.service import (
|
|
29
|
+
run as service_run,
|
|
30
|
+
)
|
|
31
|
+
from kagent.service import (
|
|
32
|
+
runtime as service_runtime,
|
|
33
|
+
)
|
|
34
|
+
from kagent.service import (
|
|
35
|
+
safety as service_safety,
|
|
36
|
+
)
|
|
37
|
+
from kagent.service import (
|
|
38
|
+
server as service_server,
|
|
39
|
+
)
|
|
40
|
+
from kagent.service import (
|
|
41
|
+
status as service_status,
|
|
42
|
+
)
|
|
43
|
+
from kagent.service import (
|
|
44
|
+
trace_store as service_trace_store,
|
|
45
|
+
)
|
|
46
|
+
from kagent.service import (
|
|
47
|
+
transport as service_transport,
|
|
48
|
+
)
|
|
49
|
+
from kagent.service.runtime import (
|
|
50
|
+
ServiceConcurrencyLimiter,
|
|
51
|
+
ServiceConfig,
|
|
52
|
+
ServiceIdempotencyCache,
|
|
53
|
+
ServiceMetrics,
|
|
54
|
+
ServiceRateLimiter,
|
|
55
|
+
access_log_record,
|
|
56
|
+
)
|
|
57
|
+
from kagent.service.active_runs import ActiveRunRegistry
|
|
58
|
+
from kagent.utils.json_output import json_ready
|
|
59
|
+
|
|
60
|
+
_ALLOWED_HTTP_METHODS = service_contract.ALLOWED_HTTP_METHODS
|
|
61
|
+
_KNOWN_METRICS_PATHS = frozenset(
|
|
62
|
+
{
|
|
63
|
+
"/config",
|
|
64
|
+
"/health",
|
|
65
|
+
"/metrics",
|
|
66
|
+
"/metrics.prom",
|
|
67
|
+
"/openapi.json",
|
|
68
|
+
"/ready",
|
|
69
|
+
"/run",
|
|
70
|
+
"/runtime/resume",
|
|
71
|
+
"/runtime/approvals",
|
|
72
|
+
"/runtime/approvals/summary",
|
|
73
|
+
"/runtime/graph",
|
|
74
|
+
"/runtime/policy",
|
|
75
|
+
"/runtime/runs",
|
|
76
|
+
"/runtime/runs/summary",
|
|
77
|
+
"/runtime/runs/{run_id}",
|
|
78
|
+
"/runtime/runs/{run_id}/artifacts",
|
|
79
|
+
"/runtime/runs/{run_id}/artifacts/{artifact_id}",
|
|
80
|
+
"/runtime/runs/{run_id}/cancel",
|
|
81
|
+
"/runtime/runs/{run_id}/timeline",
|
|
82
|
+
"/runtime/run",
|
|
83
|
+
"/runtime/tools",
|
|
84
|
+
"/tools",
|
|
85
|
+
"/version",
|
|
86
|
+
}
|
|
87
|
+
)
|
|
88
|
+
_UNKNOWN_METRICS_PATH = "__unknown__"
|
|
89
|
+
access_log_schema = service_runtime.access_log_schema
|
|
90
|
+
service_openapi = service_contract.service_openapi
|
|
91
|
+
readiness_payload = service_status.readiness_payload
|
|
92
|
+
service_config_snapshot = service_status.service_config_snapshot
|
|
93
|
+
_failure_payload = service_errors.failure_payload
|
|
94
|
+
_authorized = service_safety.authorized
|
|
95
|
+
_json_content_type = service_safety.json_content_type
|
|
96
|
+
_json_ready = json_ready
|
|
97
|
+
_rate_limit_key = service_safety.rate_limit_key
|
|
98
|
+
_request_id_from_headers = service_safety.request_id_from_headers
|
|
99
|
+
_persist_trace = service_trace_store.persist_trace
|
|
100
|
+
handle_request = service_router.handle_request
|
|
101
|
+
_handle_run = service_run.execute_run_request
|
|
102
|
+
_run_with_timeout = service_run.run_with_timeout
|
|
103
|
+
_optional_int = service_run.optional_int
|
|
104
|
+
_payload_error_code = service_transport.error_code_from_payload
|
|
105
|
+
_metrics_snapshot = service_router.metrics_snapshot
|
|
106
|
+
_agent_run_status = service_router.agent_run_status
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class _SignalShutdown(Exception):
|
|
110
|
+
def __init__(self, signum: int) -> None:
|
|
111
|
+
self.signum = signum
|
|
112
|
+
super().__init__(f"received signal {signum}")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def create_server(
|
|
116
|
+
host: str,
|
|
117
|
+
port: int,
|
|
118
|
+
*,
|
|
119
|
+
config: Optional[ServiceConfig] = None,
|
|
120
|
+
) -> ThreadingHTTPServer:
|
|
121
|
+
return service_server.create_threading_server(
|
|
122
|
+
host,
|
|
123
|
+
port,
|
|
124
|
+
_AgentRequestHandler,
|
|
125
|
+
config=config,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
130
|
+
_suppress_noisy_dependency_warnings()
|
|
131
|
+
parser = argparse.ArgumentParser(description="Serve the kagent API.")
|
|
132
|
+
try:
|
|
133
|
+
defaults = ServiceConfig.from_env()
|
|
134
|
+
except ValueError as exc:
|
|
135
|
+
parser.error(str(exc))
|
|
136
|
+
parser.add_argument("--host", default=defaults.host)
|
|
137
|
+
parser.add_argument("--port", type=int, default=defaults.port)
|
|
138
|
+
parser.add_argument("--max-request-bytes", type=int, default=defaults.max_request_bytes)
|
|
139
|
+
parser.add_argument("--max-goal-chars", type=int, default=defaults.max_goal_chars)
|
|
140
|
+
parser.add_argument("--auth-token", default=defaults.auth_token)
|
|
141
|
+
parser.add_argument("--rate-limit-per-minute", type=int, default=defaults.rate_limit_per_minute)
|
|
142
|
+
parser.add_argument("--max-concurrent-runs", type=int, default=defaults.max_concurrent_runs)
|
|
143
|
+
parser.add_argument(
|
|
144
|
+
"--idempotency-cache-size",
|
|
145
|
+
type=int,
|
|
146
|
+
default=defaults.idempotency_cache_size,
|
|
147
|
+
)
|
|
148
|
+
parser.add_argument(
|
|
149
|
+
"--idempotency-cache-path",
|
|
150
|
+
default=defaults.idempotency_cache_path,
|
|
151
|
+
help="Optional SQLite file for persistent/shared Idempotency-Key responses.",
|
|
152
|
+
)
|
|
153
|
+
parser.add_argument(
|
|
154
|
+
"--runtime-max-iterations",
|
|
155
|
+
type=int,
|
|
156
|
+
default=defaults.runtime_max_iterations,
|
|
157
|
+
help="Maximum runtime planner iterations per request.",
|
|
158
|
+
)
|
|
159
|
+
parser.add_argument(
|
|
160
|
+
"--runtime-pending-approval-stale-seconds",
|
|
161
|
+
type=int,
|
|
162
|
+
default=defaults.runtime_pending_approval_stale_seconds,
|
|
163
|
+
help=(
|
|
164
|
+
"Age threshold for stale pending approval gauges exposed by "
|
|
165
|
+
"/metrics and /metrics.prom."
|
|
166
|
+
),
|
|
167
|
+
)
|
|
168
|
+
parser.add_argument(
|
|
169
|
+
"--runtime-instance-heartbeat-seconds",
|
|
170
|
+
type=float,
|
|
171
|
+
default=defaults.runtime_instance_heartbeat_seconds,
|
|
172
|
+
help="Heartbeat interval for this service instance in the shared trace store.",
|
|
173
|
+
)
|
|
174
|
+
parser.add_argument(
|
|
175
|
+
"--runtime-orphaned-run-stale-seconds",
|
|
176
|
+
type=float,
|
|
177
|
+
default=defaults.runtime_orphaned_run_stale_seconds,
|
|
178
|
+
help="Owner heartbeat age after which interrupted runtime runs are recovered.",
|
|
179
|
+
)
|
|
180
|
+
parser.add_argument(
|
|
181
|
+
"--runtime-allowed-tools",
|
|
182
|
+
default=",".join(defaults.runtime_allowed_tools),
|
|
183
|
+
help=(
|
|
184
|
+
"Comma-separated runtime tools allowed to execute without approval; "
|
|
185
|
+
"empty uses the default policy."
|
|
186
|
+
),
|
|
187
|
+
)
|
|
188
|
+
parser.add_argument(
|
|
189
|
+
"--runtime-allowed-tools-by-subject",
|
|
190
|
+
default=_subject_tools_json(defaults.runtime_allowed_tools_by_subject),
|
|
191
|
+
help=(
|
|
192
|
+
"JSON object mapping auth_subject values to comma-separated tool lists "
|
|
193
|
+
"or arrays of tool names."
|
|
194
|
+
),
|
|
195
|
+
)
|
|
196
|
+
parser.add_argument(
|
|
197
|
+
"--allow-full-trace-response",
|
|
198
|
+
action=argparse.BooleanOptionalAction,
|
|
199
|
+
default=defaults.allow_full_trace_response,
|
|
200
|
+
help="Allow POST /run full_trace=true to return internal trace bodies.",
|
|
201
|
+
)
|
|
202
|
+
parser.add_argument(
|
|
203
|
+
"--protect-diagnostics",
|
|
204
|
+
action=argparse.BooleanOptionalAction,
|
|
205
|
+
default=defaults.protect_diagnostics,
|
|
206
|
+
help="Require bearer auth for diagnostic GET endpoints.",
|
|
207
|
+
)
|
|
208
|
+
parser.add_argument(
|
|
209
|
+
"--trust-forwarded-for",
|
|
210
|
+
action="store_true",
|
|
211
|
+
default=defaults.trust_forwarded_for,
|
|
212
|
+
)
|
|
213
|
+
parser.add_argument("--trace-dir", default=defaults.trace_dir)
|
|
214
|
+
parser.add_argument(
|
|
215
|
+
"--runtime-workspace-dir",
|
|
216
|
+
default=defaults.runtime_workspace_dir,
|
|
217
|
+
help=(
|
|
218
|
+
"Optional runtime virtual workspace root for /workspace, "
|
|
219
|
+
"/reports, /logs, /policies, and /memories assets."
|
|
220
|
+
),
|
|
221
|
+
)
|
|
222
|
+
parser.add_argument("--run-timeout-seconds", type=float, default=defaults.run_timeout_seconds)
|
|
223
|
+
parser.add_argument(
|
|
224
|
+
"--request-timeout-seconds",
|
|
225
|
+
type=float,
|
|
226
|
+
default=defaults.request_timeout_seconds,
|
|
227
|
+
)
|
|
228
|
+
args = parser.parse_args(argv)
|
|
229
|
+
try:
|
|
230
|
+
config = ServiceConfig(
|
|
231
|
+
host=args.host,
|
|
232
|
+
port=args.port,
|
|
233
|
+
max_request_bytes=args.max_request_bytes,
|
|
234
|
+
max_goal_chars=args.max_goal_chars,
|
|
235
|
+
auth_token=args.auth_token,
|
|
236
|
+
auth_tokens=defaults.auth_tokens,
|
|
237
|
+
rate_limit_per_minute=args.rate_limit_per_minute,
|
|
238
|
+
max_concurrent_runs=args.max_concurrent_runs,
|
|
239
|
+
idempotency_cache_size=args.idempotency_cache_size,
|
|
240
|
+
idempotency_cache_path=args.idempotency_cache_path,
|
|
241
|
+
runtime_allowed_tools=_csv_tuple(args.runtime_allowed_tools),
|
|
242
|
+
runtime_allowed_tools_by_subject=_subject_tools_map(
|
|
243
|
+
args.runtime_allowed_tools_by_subject
|
|
244
|
+
),
|
|
245
|
+
runtime_max_iterations=args.runtime_max_iterations,
|
|
246
|
+
runtime_pending_approval_stale_seconds=(
|
|
247
|
+
args.runtime_pending_approval_stale_seconds
|
|
248
|
+
),
|
|
249
|
+
runtime_instance_heartbeat_seconds=(
|
|
250
|
+
args.runtime_instance_heartbeat_seconds
|
|
251
|
+
),
|
|
252
|
+
runtime_orphaned_run_stale_seconds=(
|
|
253
|
+
args.runtime_orphaned_run_stale_seconds
|
|
254
|
+
),
|
|
255
|
+
allow_full_trace_response=args.allow_full_trace_response,
|
|
256
|
+
protect_diagnostics=args.protect_diagnostics,
|
|
257
|
+
trust_forwarded_for=args.trust_forwarded_for,
|
|
258
|
+
trace_dir=args.trace_dir,
|
|
259
|
+
runtime_workspace_dir=args.runtime_workspace_dir,
|
|
260
|
+
redis_url=defaults.redis_url,
|
|
261
|
+
milvus_url=defaults.milvus_url,
|
|
262
|
+
kafka_audit_url=defaults.kafka_audit_url,
|
|
263
|
+
kafka_audit_topic=defaults.kafka_audit_topic,
|
|
264
|
+
external_backend_timeout_seconds=(
|
|
265
|
+
defaults.external_backend_timeout_seconds
|
|
266
|
+
),
|
|
267
|
+
run_timeout_seconds=args.run_timeout_seconds,
|
|
268
|
+
request_timeout_seconds=args.request_timeout_seconds,
|
|
269
|
+
)
|
|
270
|
+
except ValueError as exc:
|
|
271
|
+
parser.error(str(exc))
|
|
272
|
+
|
|
273
|
+
server = create_server(config.host, config.port, config=config)
|
|
274
|
+
host, port = server.server_address
|
|
275
|
+
print(json.dumps({"status": "serving", "host": host, "port": port}), flush=True)
|
|
276
|
+
previous_sigterm = signal.getsignal(signal.SIGTERM)
|
|
277
|
+
signal.signal(signal.SIGTERM, _raise_signal_shutdown)
|
|
278
|
+
try:
|
|
279
|
+
server.serve_forever()
|
|
280
|
+
except _SignalShutdown as exc:
|
|
281
|
+
return 128 + exc.signum
|
|
282
|
+
except KeyboardInterrupt:
|
|
283
|
+
return 130
|
|
284
|
+
finally:
|
|
285
|
+
signal.signal(signal.SIGTERM, previous_sigterm)
|
|
286
|
+
server.server_close()
|
|
287
|
+
return 0
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _suppress_noisy_dependency_warnings() -> None:
|
|
291
|
+
warnings.filterwarnings("ignore")
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _raise_signal_shutdown(signum: int, _frame: Any) -> None:
|
|
295
|
+
raise _SignalShutdown(signum)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _csv_tuple(value: str) -> tuple[str, ...]:
|
|
299
|
+
if not value:
|
|
300
|
+
return ()
|
|
301
|
+
return tuple(sorted({item.strip() for item in value.split(",") if item.strip()}))
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _subject_tools_json(value: Mapping[str, tuple[str, ...]]) -> str:
|
|
305
|
+
if not value:
|
|
306
|
+
return ""
|
|
307
|
+
return json.dumps({subject: list(tools) for subject, tools in value.items()})
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _subject_tools_map(value: str) -> dict[str, tuple[str, ...]]:
|
|
311
|
+
if not value:
|
|
312
|
+
return {}
|
|
313
|
+
payload = json.loads(value)
|
|
314
|
+
if not isinstance(payload, dict):
|
|
315
|
+
raise ValueError("runtime_allowed_tools_by_subject must be a JSON object")
|
|
316
|
+
result: dict[str, tuple[str, ...]] = {}
|
|
317
|
+
for subject, tools in payload.items():
|
|
318
|
+
if isinstance(tools, str):
|
|
319
|
+
result[str(subject)] = _csv_tuple(tools)
|
|
320
|
+
elif isinstance(tools, list) and all(isinstance(item, str) for item in tools):
|
|
321
|
+
result[str(subject)] = tuple(sorted({item.strip() for item in tools if item.strip()}))
|
|
322
|
+
else:
|
|
323
|
+
raise ValueError(
|
|
324
|
+
"runtime_allowed_tools_by_subject values must be strings or arrays of strings"
|
|
325
|
+
)
|
|
326
|
+
return result
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
class _AgentRequestHandler(BaseHTTPRequestHandler):
|
|
330
|
+
server_version = "kagentHTTP/0.1"
|
|
331
|
+
|
|
332
|
+
def version_string(self) -> str:
|
|
333
|
+
return self.server_version
|
|
334
|
+
|
|
335
|
+
def setup(self) -> None:
|
|
336
|
+
super().setup()
|
|
337
|
+
self.connection.settimeout(self._config().request_timeout_seconds)
|
|
338
|
+
|
|
339
|
+
def do_GET(self) -> None:
|
|
340
|
+
self._started_at = time.perf_counter()
|
|
341
|
+
self._request_id_value = _request_id_from_headers(self.headers)
|
|
342
|
+
self._idempotency_key_present = None
|
|
343
|
+
if self._has_ambiguous_authorization():
|
|
344
|
+
self._send_unauthorized_response()
|
|
345
|
+
return
|
|
346
|
+
self._send_response(
|
|
347
|
+
*handle_request(
|
|
348
|
+
"GET",
|
|
349
|
+
self.path,
|
|
350
|
+
b"",
|
|
351
|
+
headers=dict(self.headers.items()),
|
|
352
|
+
config=self._config(),
|
|
353
|
+
metrics=self._metrics(),
|
|
354
|
+
rate_limiter=self._rate_limiter(),
|
|
355
|
+
concurrency_limiter=self._concurrency_limiter(),
|
|
356
|
+
active_run_registry=self._active_run_registry(),
|
|
357
|
+
idempotency_cache=self._idempotency_cache(),
|
|
358
|
+
remote_addr=self._remote_addr(),
|
|
359
|
+
)
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
def do_HEAD(self) -> None:
|
|
363
|
+
self._started_at = time.perf_counter()
|
|
364
|
+
self._request_id_value = _request_id_from_headers(self.headers)
|
|
365
|
+
self._idempotency_key_present = None
|
|
366
|
+
if self._has_ambiguous_authorization():
|
|
367
|
+
self._send_unauthorized_response()
|
|
368
|
+
return
|
|
369
|
+
self._send_response(
|
|
370
|
+
*handle_request(
|
|
371
|
+
"GET",
|
|
372
|
+
self.path,
|
|
373
|
+
b"",
|
|
374
|
+
headers=dict(self.headers.items()),
|
|
375
|
+
config=self._config(),
|
|
376
|
+
metrics=self._metrics(),
|
|
377
|
+
rate_limiter=self._rate_limiter(),
|
|
378
|
+
concurrency_limiter=self._concurrency_limiter(),
|
|
379
|
+
active_run_registry=self._active_run_registry(),
|
|
380
|
+
idempotency_cache=self._idempotency_cache(),
|
|
381
|
+
remote_addr=self._remote_addr(),
|
|
382
|
+
),
|
|
383
|
+
write_body=False,
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
def do_OPTIONS(self) -> None:
|
|
387
|
+
self._started_at = time.perf_counter()
|
|
388
|
+
self._request_id_value = _request_id_from_headers(self.headers)
|
|
389
|
+
self._idempotency_key_present = None
|
|
390
|
+
self._send_empty_response(204, headers={"Allow": _ALLOWED_HTTP_METHODS})
|
|
391
|
+
|
|
392
|
+
def do_POST(self) -> None:
|
|
393
|
+
self._started_at = time.perf_counter()
|
|
394
|
+
self._request_id_value = _request_id_from_headers(self.headers)
|
|
395
|
+
self._request_body_bytes = None
|
|
396
|
+
if self._has_ambiguous_authorization():
|
|
397
|
+
self._idempotency_key_present = None
|
|
398
|
+
self._send_unauthorized_response()
|
|
399
|
+
return
|
|
400
|
+
expect_headers = self.headers.get_all("Expect", [])
|
|
401
|
+
if expect_headers:
|
|
402
|
+
self._idempotency_key_present = None
|
|
403
|
+
self._send_response(
|
|
404
|
+
417,
|
|
405
|
+
_failure_payload(
|
|
406
|
+
service_errors.EXPECTATION_FAILED,
|
|
407
|
+
"expect header is unsupported",
|
|
408
|
+
),
|
|
409
|
+
)
|
|
410
|
+
return
|
|
411
|
+
idempotency_keys = self.headers.get_all("Idempotency-Key", [])
|
|
412
|
+
self._idempotency_key_present = bool(idempotency_keys)
|
|
413
|
+
if len(idempotency_keys) > 1:
|
|
414
|
+
self._send_response(
|
|
415
|
+
400,
|
|
416
|
+
_failure_payload(
|
|
417
|
+
service_errors.INVALID_IDEMPOTENCY_KEY,
|
|
418
|
+
"idempotency key must be single-valued",
|
|
419
|
+
),
|
|
420
|
+
)
|
|
421
|
+
return
|
|
422
|
+
transfer_encodings = self.headers.get_all("Transfer-Encoding", [])
|
|
423
|
+
if transfer_encodings:
|
|
424
|
+
self._send_response(
|
|
425
|
+
400,
|
|
426
|
+
_failure_payload(
|
|
427
|
+
service_errors.INVALID_TRANSFER_ENCODING,
|
|
428
|
+
"transfer-encoding is unsupported",
|
|
429
|
+
),
|
|
430
|
+
)
|
|
431
|
+
return
|
|
432
|
+
content_types = self.headers.get_all("Content-Type", [])
|
|
433
|
+
if len(content_types) > 1:
|
|
434
|
+
self._send_response(
|
|
435
|
+
415,
|
|
436
|
+
_failure_payload(
|
|
437
|
+
service_errors.UNSUPPORTED_MEDIA_TYPE,
|
|
438
|
+
"content-type must be single-valued application/json",
|
|
439
|
+
),
|
|
440
|
+
)
|
|
441
|
+
return
|
|
442
|
+
content_lengths = self.headers.get_all("Content-Length", [])
|
|
443
|
+
if len(content_lengths) != 1:
|
|
444
|
+
self._send_response(
|
|
445
|
+
400,
|
|
446
|
+
_failure_payload(service_errors.INVALID_CONTENT_LENGTH, "invalid content-length"),
|
|
447
|
+
)
|
|
448
|
+
return
|
|
449
|
+
try:
|
|
450
|
+
length = int(content_lengths[0])
|
|
451
|
+
except ValueError:
|
|
452
|
+
self._send_response(
|
|
453
|
+
400,
|
|
454
|
+
_failure_payload(service_errors.INVALID_CONTENT_LENGTH, "invalid content-length"),
|
|
455
|
+
)
|
|
456
|
+
return
|
|
457
|
+
if length < 0:
|
|
458
|
+
self._send_response(
|
|
459
|
+
400,
|
|
460
|
+
_failure_payload(service_errors.INVALID_CONTENT_LENGTH, "invalid content-length"),
|
|
461
|
+
)
|
|
462
|
+
return
|
|
463
|
+
if length > self._config().max_request_bytes:
|
|
464
|
+
self._send_response(
|
|
465
|
+
413,
|
|
466
|
+
_failure_payload(service_errors.REQUEST_TOO_LARGE, "request body too large"),
|
|
467
|
+
)
|
|
468
|
+
return
|
|
469
|
+
try:
|
|
470
|
+
body = self.rfile.read(length) if length else b""
|
|
471
|
+
except socket.timeout:
|
|
472
|
+
payload = _failure_payload(
|
|
473
|
+
service_errors.REQUEST_BODY_TIMEOUT,
|
|
474
|
+
"timed out while reading request body",
|
|
475
|
+
)
|
|
476
|
+
payload["retry_after_seconds"] = "1"
|
|
477
|
+
self._send_response(
|
|
478
|
+
408,
|
|
479
|
+
payload,
|
|
480
|
+
)
|
|
481
|
+
return
|
|
482
|
+
self._request_body_bytes = len(body)
|
|
483
|
+
if len(body) != length:
|
|
484
|
+
self._send_response(
|
|
485
|
+
400,
|
|
486
|
+
_failure_payload(
|
|
487
|
+
service_errors.INCOMPLETE_REQUEST_BODY,
|
|
488
|
+
"request body ended before content-length bytes were read",
|
|
489
|
+
),
|
|
490
|
+
)
|
|
491
|
+
return
|
|
492
|
+
self._send_response(
|
|
493
|
+
*handle_request(
|
|
494
|
+
"POST",
|
|
495
|
+
self.path,
|
|
496
|
+
body,
|
|
497
|
+
headers=dict(self.headers.items()),
|
|
498
|
+
config=self._config(),
|
|
499
|
+
metrics=self._metrics(),
|
|
500
|
+
rate_limiter=self._rate_limiter(),
|
|
501
|
+
concurrency_limiter=self._concurrency_limiter(),
|
|
502
|
+
active_run_registry=self._active_run_registry(),
|
|
503
|
+
idempotency_cache=self._idempotency_cache(),
|
|
504
|
+
remote_addr=self._remote_addr(),
|
|
505
|
+
)
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
def do_DELETE(self) -> None:
|
|
509
|
+
self._send_method_not_allowed()
|
|
510
|
+
|
|
511
|
+
def do_PATCH(self) -> None:
|
|
512
|
+
self._send_method_not_allowed()
|
|
513
|
+
|
|
514
|
+
def do_PUT(self) -> None:
|
|
515
|
+
self._send_method_not_allowed()
|
|
516
|
+
|
|
517
|
+
def log_message(self, format: str, *args: Any) -> None:
|
|
518
|
+
return None
|
|
519
|
+
|
|
520
|
+
def _send_method_not_allowed(self) -> None:
|
|
521
|
+
self._started_at = time.perf_counter()
|
|
522
|
+
self._request_id_value = _request_id_from_headers(self.headers)
|
|
523
|
+
self._idempotency_key_present = None
|
|
524
|
+
if self._has_ambiguous_authorization():
|
|
525
|
+
self._send_unauthorized_response()
|
|
526
|
+
return
|
|
527
|
+
self._send_response(
|
|
528
|
+
405,
|
|
529
|
+
_failure_payload(service_errors.METHOD_NOT_ALLOWED, "method not allowed"),
|
|
530
|
+
headers={"Allow": _ALLOWED_HTTP_METHODS},
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
def _send_response(
|
|
534
|
+
self,
|
|
535
|
+
status_code: int,
|
|
536
|
+
payload: Any,
|
|
537
|
+
*,
|
|
538
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
539
|
+
write_body: bool = True,
|
|
540
|
+
) -> None:
|
|
541
|
+
data, content_type = service_transport.response_body(payload)
|
|
542
|
+
self.send_response(status_code)
|
|
543
|
+
self.send_header("Content-Type", content_type)
|
|
544
|
+
self.send_header("X-Content-Type-Options", service_transport.NOSNIFF_HEADER_VALUE)
|
|
545
|
+
self.send_header("Cache-Control", service_transport.CACHE_CONTROL_HEADER_VALUE)
|
|
546
|
+
self.send_header("Referrer-Policy", service_transport.REFERRER_POLICY_HEADER_VALUE)
|
|
547
|
+
self.send_header(
|
|
548
|
+
"Content-Security-Policy",
|
|
549
|
+
service_transport.CONTENT_SECURITY_POLICY_HEADER_VALUE,
|
|
550
|
+
)
|
|
551
|
+
self.send_header("X-Frame-Options", service_transport.X_FRAME_OPTIONS_HEADER_VALUE)
|
|
552
|
+
self.send_header("Content-Length", str(len(data)))
|
|
553
|
+
self.send_header("X-Request-ID", self._request_id())
|
|
554
|
+
run_id = _payload_field(payload, "run_id")
|
|
555
|
+
if _safe_response_header_value(run_id):
|
|
556
|
+
self.send_header("X-Run-ID", run_id)
|
|
557
|
+
trace_path = _payload_field(payload, "trace_path")
|
|
558
|
+
if _safe_trace_path_response_header_value(trace_path):
|
|
559
|
+
self.send_header("X-Trace-Path", trace_path)
|
|
560
|
+
if status_code == 401:
|
|
561
|
+
self.send_header("WWW-Authenticate", "Bearer")
|
|
562
|
+
retry_after = _retry_after_value(status_code, payload)
|
|
563
|
+
if retry_after:
|
|
564
|
+
self.send_header("Retry-After", retry_after)
|
|
565
|
+
for header_name, header_value in (headers or {}).items():
|
|
566
|
+
self.send_header(header_name, header_value)
|
|
567
|
+
self.end_headers()
|
|
568
|
+
if write_body:
|
|
569
|
+
self.wfile.write(data)
|
|
570
|
+
self._write_access_log(status_code, payload)
|
|
571
|
+
|
|
572
|
+
def _send_empty_response(
|
|
573
|
+
self,
|
|
574
|
+
status_code: int,
|
|
575
|
+
*,
|
|
576
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
577
|
+
) -> None:
|
|
578
|
+
self.send_response(status_code)
|
|
579
|
+
self.send_header("X-Content-Type-Options", service_transport.NOSNIFF_HEADER_VALUE)
|
|
580
|
+
self.send_header("Cache-Control", service_transport.CACHE_CONTROL_HEADER_VALUE)
|
|
581
|
+
self.send_header("Referrer-Policy", service_transport.REFERRER_POLICY_HEADER_VALUE)
|
|
582
|
+
self.send_header(
|
|
583
|
+
"Content-Security-Policy",
|
|
584
|
+
service_transport.CONTENT_SECURITY_POLICY_HEADER_VALUE,
|
|
585
|
+
)
|
|
586
|
+
self.send_header("X-Frame-Options", service_transport.X_FRAME_OPTIONS_HEADER_VALUE)
|
|
587
|
+
self.send_header("Content-Length", "0")
|
|
588
|
+
self.send_header("X-Request-ID", self._request_id())
|
|
589
|
+
for header_name, header_value in (headers or {}).items():
|
|
590
|
+
self.send_header(header_name, header_value)
|
|
591
|
+
self.end_headers()
|
|
592
|
+
self._write_access_log(status_code, {})
|
|
593
|
+
|
|
594
|
+
def _send_unauthorized_response(self) -> None:
|
|
595
|
+
self._send_response(
|
|
596
|
+
401,
|
|
597
|
+
_failure_payload(service_errors.UNAUTHORIZED, "unauthorized"),
|
|
598
|
+
)
|
|
599
|
+
|
|
600
|
+
def _has_ambiguous_authorization(self) -> bool:
|
|
601
|
+
authorization_headers = self.headers.get_all("Authorization", [])
|
|
602
|
+
return bool(self._config().auth_required and len(authorization_headers) > 1)
|
|
603
|
+
|
|
604
|
+
def _config(self) -> ServiceConfig:
|
|
605
|
+
return getattr(self.server, "service_config", ServiceConfig())
|
|
606
|
+
|
|
607
|
+
def _metrics(self) -> ServiceMetrics:
|
|
608
|
+
return getattr(self.server, "service_metrics", ServiceMetrics())
|
|
609
|
+
|
|
610
|
+
def _rate_limiter(self) -> ServiceRateLimiter:
|
|
611
|
+
return getattr(self.server, "service_rate_limiter", ServiceRateLimiter(limit_per_minute=0))
|
|
612
|
+
|
|
613
|
+
def _concurrency_limiter(self) -> ServiceConcurrencyLimiter:
|
|
614
|
+
return getattr(
|
|
615
|
+
self.server,
|
|
616
|
+
"service_concurrency_limiter",
|
|
617
|
+
ServiceConcurrencyLimiter(max_concurrent_runs=0),
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
def _idempotency_cache(self) -> ServiceIdempotencyCache:
|
|
621
|
+
return getattr(
|
|
622
|
+
self.server,
|
|
623
|
+
"service_idempotency_cache",
|
|
624
|
+
ServiceIdempotencyCache(max_entries=0),
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
def _active_run_registry(self) -> ActiveRunRegistry:
|
|
628
|
+
return getattr(
|
|
629
|
+
self.server,
|
|
630
|
+
"service_active_run_registry",
|
|
631
|
+
ActiveRunRegistry(),
|
|
632
|
+
)
|
|
633
|
+
|
|
634
|
+
def _request_id(self) -> str:
|
|
635
|
+
return getattr(self, "_request_id_value", str(uuid4()))
|
|
636
|
+
|
|
637
|
+
def _write_access_log(self, status_code: int, payload: Any) -> None:
|
|
638
|
+
started_at = getattr(self, "_started_at", time.perf_counter())
|
|
639
|
+
duration_seconds = time.perf_counter() - started_at
|
|
640
|
+
error_code = _payload_error_code(payload)
|
|
641
|
+
run_id = _payload_field(payload, "run_id")
|
|
642
|
+
trace_path = _payload_field(payload, "trace_path")
|
|
643
|
+
runtime_owner_auth_subject = _payload_field(payload, "auth_subject")
|
|
644
|
+
resumed_by_auth_subject = _payload_field(payload, "resumed_by_auth_subject")
|
|
645
|
+
approved_by_auth_subject = _payload_field(payload, "approved_by_auth_subject")
|
|
646
|
+
idempotency_key_present = getattr(self, "_idempotency_key_present", None)
|
|
647
|
+
request_body_bytes = getattr(self, "_request_body_bytes", None)
|
|
648
|
+
config = self._config() if hasattr(self, "_config") else ServiceConfig()
|
|
649
|
+
raw_headers = dict(self.headers.items()) if hasattr(self, "headers") else {}
|
|
650
|
+
record = access_log_record(
|
|
651
|
+
method=self.command,
|
|
652
|
+
path=urlparse(self.path).path,
|
|
653
|
+
status_code=status_code,
|
|
654
|
+
duration_seconds=duration_seconds,
|
|
655
|
+
request_id=self._request_id(),
|
|
656
|
+
remote_addr=self._remote_addr(),
|
|
657
|
+
error_code=error_code,
|
|
658
|
+
run_id=run_id,
|
|
659
|
+
trace_path=trace_path,
|
|
660
|
+
idempotency_key_present=idempotency_key_present,
|
|
661
|
+
request_body_bytes=request_body_bytes,
|
|
662
|
+
auth_subject=service_safety.authenticated_subject(
|
|
663
|
+
raw_headers,
|
|
664
|
+
config.auth_token,
|
|
665
|
+
config.auth_tokens,
|
|
666
|
+
),
|
|
667
|
+
runtime_owner_auth_subject=runtime_owner_auth_subject,
|
|
668
|
+
resumed_by_auth_subject=resumed_by_auth_subject,
|
|
669
|
+
approved_by_auth_subject=approved_by_auth_subject,
|
|
670
|
+
)
|
|
671
|
+
self._metrics().record(
|
|
672
|
+
method=record["method"],
|
|
673
|
+
path=_metrics_path(record["path"]),
|
|
674
|
+
status_code=status_code,
|
|
675
|
+
duration_seconds=duration_seconds,
|
|
676
|
+
error_code=error_code,
|
|
677
|
+
auth_subject=record.get("auth_subject", ""),
|
|
678
|
+
)
|
|
679
|
+
sys.stderr.write(json.dumps(record, sort_keys=True) + "\n")
|
|
680
|
+
sys.stderr.flush()
|
|
681
|
+
|
|
682
|
+
def _remote_addr(self) -> str:
|
|
683
|
+
return str(self.client_address[0])
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def _payload_field(payload: Any, field_name: str) -> str:
|
|
687
|
+
if isinstance(payload, dict):
|
|
688
|
+
return str(payload.get(field_name, ""))
|
|
689
|
+
return ""
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def _metrics_path(path: str) -> str:
|
|
693
|
+
if path == "/runtime/approvals/summary":
|
|
694
|
+
return "/runtime/approvals/summary"
|
|
695
|
+
if path == "/runtime/approvals":
|
|
696
|
+
return "/runtime/approvals"
|
|
697
|
+
if path == "/runtime/policy":
|
|
698
|
+
return "/runtime/policy"
|
|
699
|
+
if path == "/runtime/runs/summary":
|
|
700
|
+
return "/runtime/runs/summary"
|
|
701
|
+
if path.startswith("/runtime/runs/") and "/artifacts/" in path:
|
|
702
|
+
return "/runtime/runs/{run_id}/artifacts/{artifact_id}"
|
|
703
|
+
if path.startswith("/runtime/runs/") and path.endswith("/artifacts"):
|
|
704
|
+
return "/runtime/runs/{run_id}/artifacts"
|
|
705
|
+
if path.startswith("/runtime/runs/") and path.endswith("/cancel"):
|
|
706
|
+
return "/runtime/runs/{run_id}/cancel"
|
|
707
|
+
if path.startswith("/runtime/runs/") and path.endswith("/timeline"):
|
|
708
|
+
return "/runtime/runs/{run_id}/timeline"
|
|
709
|
+
if path.startswith("/runtime/runs/"):
|
|
710
|
+
return "/runtime/runs/{run_id}"
|
|
711
|
+
return path if path in _KNOWN_METRICS_PATHS else _UNKNOWN_METRICS_PATH
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def _safe_response_header_value(value: str) -> bool:
|
|
715
|
+
return service_safety.safe_request_id(value)
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def _safe_trace_path_response_header_value(value: str) -> bool:
|
|
719
|
+
return bool(value and len(value) <= 1024 and service_safety.safe_header_value(value))
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def _retry_after_value(status_code: int, payload: Any) -> str:
|
|
723
|
+
error_code = _payload_error_code(payload)
|
|
724
|
+
if status_code == 429 and error_code == service_errors.RATE_LIMIT_EXCEEDED:
|
|
725
|
+
retry_after_seconds = _payload_field(payload, "retry_after_seconds")
|
|
726
|
+
return retry_after_seconds if retry_after_seconds else "60"
|
|
727
|
+
if status_code == 503 and error_code == service_errors.TOO_MANY_CONCURRENT_RUNS:
|
|
728
|
+
retry_after_seconds = _payload_field(payload, "retry_after_seconds")
|
|
729
|
+
return retry_after_seconds if retry_after_seconds else "1"
|
|
730
|
+
if status_code == 408 and error_code == service_errors.REQUEST_BODY_TIMEOUT:
|
|
731
|
+
retry_after_seconds = _payload_field(payload, "retry_after_seconds")
|
|
732
|
+
return retry_after_seconds if retry_after_seconds else "1"
|
|
733
|
+
return ""
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
if __name__ == "__main__":
|
|
737
|
+
raise SystemExit(main())
|