agent-control-panel 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agent_control_panel/__init__.py +102 -0
- agent_control_panel/_content.py +138 -0
- agent_control_panel/_control.py +202 -0
- agent_control_panel/_http.py +99 -0
- agent_control_panel/_lifecycle.py +131 -0
- agent_control_panel/_reporter.py +182 -0
- agent_control_panel/_transport.py +86 -0
- agent_control_panel/_version.py +1 -0
- agent_control_panel/_webhook.py +62 -0
- agent_control_panel/client.py +328 -0
- agent_control_panel/errors.py +16 -0
- agent_control_panel/py.typed +0 -0
- agent_control_panel/signature.py +168 -0
- agent_control_panel/tokens.py +115 -0
- agent_control_panel-0.1.0.dist-info/METADATA +245 -0
- agent_control_panel-0.1.0.dist-info/RECORD +18 -0
- agent_control_panel-0.1.0.dist-info/WHEEL +4 -0
- agent_control_panel-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Agent Control Panel SDK: fail-open reporting and remote control for AI agents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
from ._content import MAX_FULL_CHARS, extract_text
|
|
8
|
+
from ._http import assert_secure_url
|
|
9
|
+
from ._transport import Request, Response, Transport
|
|
10
|
+
from ._version import __version__
|
|
11
|
+
from ._webhook import WebhookResult
|
|
12
|
+
from .client import AcpClient
|
|
13
|
+
from .errors import AgentPausedError, AgentStatus
|
|
14
|
+
from .signature import (
|
|
15
|
+
MAX_SKEW_SECONDS,
|
|
16
|
+
NONCE_HEADER,
|
|
17
|
+
PROTOCOL_HEADER,
|
|
18
|
+
SIG_HEADER,
|
|
19
|
+
TS_HEADER,
|
|
20
|
+
HeadersLike,
|
|
21
|
+
NonceCache,
|
|
22
|
+
SignedPayload,
|
|
23
|
+
VerifyResult,
|
|
24
|
+
sign_payload,
|
|
25
|
+
verify_signature,
|
|
26
|
+
)
|
|
27
|
+
from .tokens import TokenUsage, extract_tokens
|
|
28
|
+
|
|
29
|
+
PACKAGE_NAME = "agent-control-panel"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def create_client(
|
|
33
|
+
*,
|
|
34
|
+
url: str,
|
|
35
|
+
api_key: str,
|
|
36
|
+
agent_id: str,
|
|
37
|
+
webhook_secret: str,
|
|
38
|
+
webhook_secret_prev: str | None = None,
|
|
39
|
+
heartbeat_seconds: float = 30.0,
|
|
40
|
+
flush_seconds: float = 5.0,
|
|
41
|
+
flush_at: int = 50,
|
|
42
|
+
max_buffer: int = 200,
|
|
43
|
+
logger: logging.Logger | None = None,
|
|
44
|
+
disable_control: bool = False,
|
|
45
|
+
allow_insecure_http: bool = False,
|
|
46
|
+
capture_content: bool = True,
|
|
47
|
+
transport: Transport | None = None,
|
|
48
|
+
) -> AcpClient:
|
|
49
|
+
"""Create a client for one agent. Call ``start()`` (or use ``with``) for heartbeats and flushes.
|
|
50
|
+
|
|
51
|
+
``capture_content=False`` sends metadata only (status, tokens, timings, model, errors): no user input,
|
|
52
|
+
summaries, full input or answer text, whatever ``report_run`` or ``wrap_agent_run`` is given.
|
|
53
|
+
|
|
54
|
+
Raises ``ValueError`` for a missing credential or an insecure URL; nothing else in the client raises
|
|
55
|
+
into your code except ``AgentPausedError`` from ``wrap_agent_run``.
|
|
56
|
+
"""
|
|
57
|
+
return AcpClient(
|
|
58
|
+
url=url,
|
|
59
|
+
api_key=api_key,
|
|
60
|
+
agent_id=agent_id,
|
|
61
|
+
webhook_secret=webhook_secret,
|
|
62
|
+
webhook_secret_prev=webhook_secret_prev,
|
|
63
|
+
heartbeat_seconds=heartbeat_seconds,
|
|
64
|
+
flush_seconds=flush_seconds,
|
|
65
|
+
flush_at=flush_at,
|
|
66
|
+
max_buffer=max_buffer,
|
|
67
|
+
logger=logger,
|
|
68
|
+
disable_control=disable_control,
|
|
69
|
+
allow_insecure_http=allow_insecure_http,
|
|
70
|
+
capture_content=capture_content,
|
|
71
|
+
transport=transport,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
__all__ = [
|
|
76
|
+
"MAX_FULL_CHARS",
|
|
77
|
+
"MAX_SKEW_SECONDS",
|
|
78
|
+
"NONCE_HEADER",
|
|
79
|
+
"PACKAGE_NAME",
|
|
80
|
+
"PROTOCOL_HEADER",
|
|
81
|
+
"SIG_HEADER",
|
|
82
|
+
"TS_HEADER",
|
|
83
|
+
"AcpClient",
|
|
84
|
+
"AgentPausedError",
|
|
85
|
+
"AgentStatus",
|
|
86
|
+
"HeadersLike",
|
|
87
|
+
"NonceCache",
|
|
88
|
+
"Request",
|
|
89
|
+
"Response",
|
|
90
|
+
"SignedPayload",
|
|
91
|
+
"TokenUsage",
|
|
92
|
+
"Transport",
|
|
93
|
+
"VerifyResult",
|
|
94
|
+
"WebhookResult",
|
|
95
|
+
"__version__",
|
|
96
|
+
"assert_secure_url",
|
|
97
|
+
"create_client",
|
|
98
|
+
"extract_text",
|
|
99
|
+
"extract_tokens",
|
|
100
|
+
"sign_payload",
|
|
101
|
+
"verify_signature",
|
|
102
|
+
]
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Run content: the answer text of LLM responses, and cutting content to what ACP stores."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
MAX_FULL_CHARS = 100_000
|
|
10
|
+
"""What ACP stores per full input/output field, in UTF-16 code units (JavaScript string length)."""
|
|
11
|
+
MAX_SUMMARY_CHARS = 2_000
|
|
12
|
+
"""What ACP stores per summary field."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _read(obj: Any, key: str) -> Any:
|
|
16
|
+
"""A key or plain attribute. Never calls anything: a callable value (a method) counts as absent."""
|
|
17
|
+
try:
|
|
18
|
+
value = obj.get(key) if isinstance(obj, Mapping) else getattr(obj, key, None)
|
|
19
|
+
except Exception:
|
|
20
|
+
return None
|
|
21
|
+
return None if callable(value) else value
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _text(value: Any) -> str | None:
|
|
25
|
+
return value if isinstance(value, str) and value else None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _is_object(value: Any) -> bool:
|
|
29
|
+
return value is not None and not isinstance(value, (str, bytes, bytearray, int, float, bool, list, tuple))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _join_text(parts: Any) -> str | None:
|
|
33
|
+
if not isinstance(parts, (list, tuple)):
|
|
34
|
+
return None
|
|
35
|
+
texts = [p if isinstance(p, str) else _text(_read(p, "text")) if _is_object(p) else None for p in parts]
|
|
36
|
+
found = [t for t in texts if t is not None]
|
|
37
|
+
return "".join(found) if found else None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _extract(result: Any) -> str | None:
|
|
41
|
+
if isinstance(result, str):
|
|
42
|
+
return result or None
|
|
43
|
+
if not _is_object(result):
|
|
44
|
+
return None
|
|
45
|
+
output_text = _text(_read(result, "output_text"))
|
|
46
|
+
if output_text:
|
|
47
|
+
return output_text
|
|
48
|
+
choices = _read(result, "choices")
|
|
49
|
+
if isinstance(choices, (list, tuple)) and choices:
|
|
50
|
+
message = _read(choices[0], "message") if _is_object(choices[0]) else None
|
|
51
|
+
content = _read(message, "content") if _is_object(message) else None
|
|
52
|
+
text = _text(content) or _join_text(content)
|
|
53
|
+
if text:
|
|
54
|
+
return text
|
|
55
|
+
content = _read(result, "content")
|
|
56
|
+
if isinstance(content, (list, tuple)):
|
|
57
|
+
blocks = [b for b in content if _is_object(b) and _read(b, "type") == "text"]
|
|
58
|
+
text = _join_text(blocks)
|
|
59
|
+
if text:
|
|
60
|
+
return text
|
|
61
|
+
response = _read(result, "response")
|
|
62
|
+
candidates = _read(result, "candidates")
|
|
63
|
+
if not isinstance(candidates, (list, tuple)) and _is_object(response):
|
|
64
|
+
candidates = _read(response, "candidates")
|
|
65
|
+
if isinstance(candidates, (list, tuple)) and candidates and _is_object(candidates[0]):
|
|
66
|
+
body = _read(candidates[0], "content")
|
|
67
|
+
text = _join_text(_read(body, "parts")) if _is_object(body) else None
|
|
68
|
+
if text:
|
|
69
|
+
return text
|
|
70
|
+
return _text(_read(result, "text"))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def extract_text(result: Any) -> str | None:
|
|
74
|
+
"""The answer text of a common LLM client response (a dict or an SDK object), or None.
|
|
75
|
+
|
|
76
|
+
- a string result is the text itself
|
|
77
|
+
- OpenAI Chat Completions: ``choices[0].message.content`` (a string or text parts)
|
|
78
|
+
- OpenAI Responses API: ``output_text``
|
|
79
|
+
- Anthropic Messages: ``content`` blocks of type ``text``
|
|
80
|
+
- Google Gemini: ``candidates[0].content.parts[].text`` (also under ``response``)
|
|
81
|
+
- ``text`` (Vercel AI SDK shape)
|
|
82
|
+
|
|
83
|
+
Only reads keys and attributes; never calls a method on the result. Never raises.
|
|
84
|
+
"""
|
|
85
|
+
try:
|
|
86
|
+
return _extract(result)
|
|
87
|
+
except Exception:
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def cut_utf16(text: str, limit: int) -> tuple[str, bool]:
|
|
92
|
+
"""Cut ``text`` to ``limit`` UTF-16 code units (how JavaScript and the ACP server count length)."""
|
|
93
|
+
if len(text) * 2 <= limit or len(text.encode("utf-16-le")) // 2 <= limit:
|
|
94
|
+
return text, False
|
|
95
|
+
units = 0
|
|
96
|
+
for index, char in enumerate(text):
|
|
97
|
+
units += 2 if ord(char) > 0xFFFF else 1
|
|
98
|
+
if units > limit:
|
|
99
|
+
return text[:index], True
|
|
100
|
+
return text, False
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def clamp_full(value: Any) -> tuple[str | None, bool]:
|
|
104
|
+
"""Serialise a full-content field like ``JSON.stringify`` and cut it to ACP's limit.
|
|
105
|
+
|
|
106
|
+
A value that cannot be serialised (circular, NaN) is left out: (None, False).
|
|
107
|
+
"""
|
|
108
|
+
if value is None:
|
|
109
|
+
return None, False
|
|
110
|
+
if isinstance(value, str):
|
|
111
|
+
text = value
|
|
112
|
+
else:
|
|
113
|
+
try:
|
|
114
|
+
text = json.dumps(value, ensure_ascii=False, separators=(",", ":"), allow_nan=False, default=str)
|
|
115
|
+
except (TypeError, ValueError, RecursionError):
|
|
116
|
+
return None, False
|
|
117
|
+
return cut_utf16(text, MAX_FULL_CHARS)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def clamp_summary(value: str | None) -> str | None:
|
|
121
|
+
return None if value is None else cut_utf16(value, MAX_SUMMARY_CHARS)[0]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def run_row(fields: dict[str, Any], capture_content: bool) -> dict[str, Any]:
|
|
125
|
+
"""The wire row for one run: content cut (and flagged) or, with capture off, left out; None dropped."""
|
|
126
|
+
content = ("input_summary", "output_summary", "user_input")
|
|
127
|
+
row = {k: v for k, v in fields.items() if k not in ("full_input", "full_output")}
|
|
128
|
+
if not capture_content:
|
|
129
|
+
for key in content:
|
|
130
|
+
row.pop(key, None)
|
|
131
|
+
full_in, in_cut = clamp_full(fields.get("full_input")) if capture_content else (None, False)
|
|
132
|
+
full_out, out_cut = clamp_full(fields.get("full_output")) if capture_content else (None, False)
|
|
133
|
+
row.update(full_input=full_in, full_output=full_out)
|
|
134
|
+
if in_cut:
|
|
135
|
+
row["full_input_truncated"] = True
|
|
136
|
+
if out_cut:
|
|
137
|
+
row["full_output_truncated"] = True
|
|
138
|
+
return {k: v for k, v in row.items() if v is not None}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Control state: status, holds, model override, prompt cache and the webhook nonce cache.
|
|
2
|
+
|
|
3
|
+
Same rules as the Node SDK (``adoptControlState`` / ``applyCommandFrom``):
|
|
4
|
+
- only an explicit resume/restart clears paused/stopped;
|
|
5
|
+
- a hold ACP set is adopted from, and released by, the heartbeat reply; a hold this app set locally never is;
|
|
6
|
+
- a heartbeat reply sent before the last applied command is ignored (``command_seq``);
|
|
7
|
+
- a model in the heartbeat reply is adopted unless the app set its own; a missing model is no instruction.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
from collections.abc import Mapping
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import Any, Literal
|
|
18
|
+
|
|
19
|
+
from .errors import AgentPausedError, AgentStatus
|
|
20
|
+
from .signature import HeadersLike, NonceCache, VerifyResult, verify_signature
|
|
21
|
+
|
|
22
|
+
Origin = Literal["acp", "local"]
|
|
23
|
+
|
|
24
|
+
PROMPT_CACHE_SECONDS = 60.0
|
|
25
|
+
MAX_MODEL_LENGTH = 120
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class CachedPrompt:
|
|
30
|
+
prompt: str
|
|
31
|
+
version: int
|
|
32
|
+
composition_hash: str | None = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class HeartbeatSnapshot:
|
|
37
|
+
seq: int
|
|
38
|
+
status: AgentStatus
|
|
39
|
+
model: str | None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ControlState:
|
|
43
|
+
def __init__(self, agent_id: str, disable_control: bool, logger: logging.Logger) -> None:
|
|
44
|
+
self.agent_id = agent_id
|
|
45
|
+
self.disable_control = disable_control
|
|
46
|
+
self._log = logger
|
|
47
|
+
self._lock = threading.Lock()
|
|
48
|
+
self._status: AgentStatus = "idle"
|
|
49
|
+
self._hold_origin: Origin | None = None
|
|
50
|
+
self._command_seq = 0
|
|
51
|
+
self._model_override: str | None = None
|
|
52
|
+
self._model_origin: Origin | None = None
|
|
53
|
+
self._prompt: CachedPrompt | None = None
|
|
54
|
+
self._prompt_cached_at: float | None = None
|
|
55
|
+
self._active_runs = 0
|
|
56
|
+
self._nonces = NonceCache()
|
|
57
|
+
|
|
58
|
+
def reset_lock_after_fork(self) -> None:
|
|
59
|
+
self._lock = threading.Lock()
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def status(self) -> AgentStatus:
|
|
63
|
+
with self._lock:
|
|
64
|
+
return self._status
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def model_override(self) -> str | None:
|
|
68
|
+
with self._lock:
|
|
69
|
+
return self._model_override
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def prompt(self) -> CachedPrompt | None:
|
|
73
|
+
with self._lock:
|
|
74
|
+
return self._prompt
|
|
75
|
+
|
|
76
|
+
# --- runs -------------------------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
def begin_run(self) -> None:
|
|
79
|
+
with self._lock:
|
|
80
|
+
if self._status in ("paused", "stopped"):
|
|
81
|
+
raise AgentPausedError(self._status)
|
|
82
|
+
self._active_runs += 1
|
|
83
|
+
self._status = "running"
|
|
84
|
+
|
|
85
|
+
def end_run(self) -> None:
|
|
86
|
+
with self._lock:
|
|
87
|
+
self._active_runs = max(0, self._active_runs - 1)
|
|
88
|
+
if self._active_runs == 0 and self._status == "running":
|
|
89
|
+
self._status = "idle"
|
|
90
|
+
|
|
91
|
+
# --- commands ---------------------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
def apply(self, command: str, payload: Mapping[str, Any] | None, origin: Origin) -> str | None:
|
|
94
|
+
if self.disable_control:
|
|
95
|
+
self._log.info("control ignored (disable_control): %s", command[:40])
|
|
96
|
+
return "ignored"
|
|
97
|
+
with self._lock:
|
|
98
|
+
if command not in ("ping", "verify"):
|
|
99
|
+
self._command_seq += 1
|
|
100
|
+
if command == "pause":
|
|
101
|
+
self._status, self._hold_origin = "paused", origin
|
|
102
|
+
return "paused"
|
|
103
|
+
if command == "resume":
|
|
104
|
+
self._status, self._hold_origin = "idle", None
|
|
105
|
+
return "resumed"
|
|
106
|
+
if command == "stop":
|
|
107
|
+
self._status, self._hold_origin = "stopped", origin
|
|
108
|
+
return "stopped"
|
|
109
|
+
if command == "restart":
|
|
110
|
+
self._status, self._hold_origin = "idle", None
|
|
111
|
+
return "restarted"
|
|
112
|
+
if command == "update_model":
|
|
113
|
+
model = payload.get("model") if payload else None
|
|
114
|
+
if isinstance(model, str) and model:
|
|
115
|
+
self._model_override, self._model_origin = model, origin
|
|
116
|
+
return f"model now {self._model_override if self._model_override is not None else 'null'}"
|
|
117
|
+
if command == "update_prompt":
|
|
118
|
+
prompt = payload.get("prompt") if payload else None
|
|
119
|
+
if isinstance(prompt, str) and prompt:
|
|
120
|
+
# The pushed text lacks the agent's skills (ACP composes them into GET /api/prompts), so
|
|
121
|
+
# it is only the fallback: the cache is marked stale and the next fetch_prompt() pulls
|
|
122
|
+
# the composed prompt.
|
|
123
|
+
sent_version = payload.get("version") if payload else None
|
|
124
|
+
version = (self._prompt.version if self._prompt else 0) + 1
|
|
125
|
+
if isinstance(sent_version, int) and not isinstance(sent_version, bool):
|
|
126
|
+
version = sent_version
|
|
127
|
+
self._prompt = CachedPrompt(prompt=prompt, version=version)
|
|
128
|
+
self._prompt_cached_at = None
|
|
129
|
+
return "prompt updated"
|
|
130
|
+
if command in ("ping", "verify"):
|
|
131
|
+
# Handshake / no-op: never touches agent status.
|
|
132
|
+
return "pong"
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
# --- heartbeat --------------------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
def heartbeat_snapshot(self) -> HeartbeatSnapshot:
|
|
138
|
+
with self._lock:
|
|
139
|
+
return HeartbeatSnapshot(self._command_seq, self._status, self._model_override)
|
|
140
|
+
|
|
141
|
+
def adopt(self, reply: Any, seq_at_send: int) -> None:
|
|
142
|
+
if self.disable_control:
|
|
143
|
+
return
|
|
144
|
+
control = reply.get("control") if isinstance(reply, dict) else None
|
|
145
|
+
agents = control.get("agents") if isinstance(control, dict) else None
|
|
146
|
+
entry = agents.get(self.agent_id) if isinstance(agents, dict) else None
|
|
147
|
+
if not isinstance(entry, dict):
|
|
148
|
+
return
|
|
149
|
+
with self._lock:
|
|
150
|
+
if seq_at_send != self._command_seq:
|
|
151
|
+
return
|
|
152
|
+
model = entry.get("model")
|
|
153
|
+
if (
|
|
154
|
+
isinstance(model, str)
|
|
155
|
+
and model
|
|
156
|
+
and len(model) <= MAX_MODEL_LENGTH
|
|
157
|
+
and self._model_origin != "local"
|
|
158
|
+
and model != self._model_override
|
|
159
|
+
):
|
|
160
|
+
self._model_override, self._model_origin = model, "acp"
|
|
161
|
+
self._log.info("model from ACP: %s", model)
|
|
162
|
+
status = entry.get("status")
|
|
163
|
+
if not isinstance(status, str):
|
|
164
|
+
return
|
|
165
|
+
if status in ("paused", "stopped"):
|
|
166
|
+
if self._hold_origin == "local" or self._status == status:
|
|
167
|
+
return
|
|
168
|
+
self._status = "paused" if status == "paused" else "stopped"
|
|
169
|
+
self._hold_origin = "acp"
|
|
170
|
+
self._log.info("control state from ACP: %s", status)
|
|
171
|
+
elif self._hold_origin == "acp":
|
|
172
|
+
self._status, self._hold_origin = "idle", None
|
|
173
|
+
self._log.info("control state from ACP: released")
|
|
174
|
+
|
|
175
|
+
# --- webhooks ---------------------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
def verify_webhook(
|
|
178
|
+
self, headers: HeadersLike, raw_body: bytes, secrets: tuple[str, str | None]
|
|
179
|
+
) -> VerifyResult:
|
|
180
|
+
# Checking and recording the nonce under one lock: two server threads must not both accept a replay.
|
|
181
|
+
with self._lock:
|
|
182
|
+
result = verify_signature(
|
|
183
|
+
secrets=secrets, raw_body=raw_body, headers=headers, seen_nonce=self._nonces.has
|
|
184
|
+
)
|
|
185
|
+
if result.ok and result.nonce:
|
|
186
|
+
self._nonces.add(result.nonce)
|
|
187
|
+
return result
|
|
188
|
+
|
|
189
|
+
# --- prompt cache -----------------------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
def fresh_prompt(self) -> CachedPrompt | None:
|
|
192
|
+
with self._lock:
|
|
193
|
+
if self._prompt is None or self._prompt_cached_at is None:
|
|
194
|
+
return None
|
|
195
|
+
if time.monotonic() - self._prompt_cached_at >= PROMPT_CACHE_SECONDS:
|
|
196
|
+
return None
|
|
197
|
+
return self._prompt
|
|
198
|
+
|
|
199
|
+
def store_prompt(self, prompt: CachedPrompt) -> None:
|
|
200
|
+
with self._lock:
|
|
201
|
+
self._prompt = prompt
|
|
202
|
+
self._prompt_cached_at = time.monotonic()
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Talking to ACP: URL rules, fail-open requests and the warnings that tell users what to fix."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import re
|
|
8
|
+
from typing import Any
|
|
9
|
+
from urllib.parse import urljoin, urlsplit, urlunsplit
|
|
10
|
+
|
|
11
|
+
from ._transport import Request, Response, Transport
|
|
12
|
+
|
|
13
|
+
LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"})
|
|
14
|
+
_CONTROL_CHARS = re.compile(r"[\x00-\x1f\x7f]")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def assert_secure_url(url: str, allow_insecure_http: bool = False) -> None:
|
|
18
|
+
"""Raise unless ``url`` is https, http to a loopback host, or http with the explicit dev opt-out."""
|
|
19
|
+
parts = urlsplit(url)
|
|
20
|
+
if not parts.scheme or not parts.netloc or not parts.hostname:
|
|
21
|
+
raise ValueError(f"create_client: url is not a valid URL: {url}")
|
|
22
|
+
if parts.scheme == "https":
|
|
23
|
+
return
|
|
24
|
+
if parts.scheme != "http":
|
|
25
|
+
raise ValueError(f"create_client: url must use https: (got {parts.scheme}:)")
|
|
26
|
+
if parts.hostname.lower() in LOOPBACK_HOSTS or allow_insecure_http:
|
|
27
|
+
return
|
|
28
|
+
raise ValueError(
|
|
29
|
+
f'create_client: refusing insecure http:// url for host "{parts.hostname}" - the API key would be '
|
|
30
|
+
"sent in cleartext. Use https://, or set allow_insecure_http=True for local development only."
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def clean(text: str, limit: int = 200) -> str:
|
|
35
|
+
"""Safe to log: no control characters (no forged log lines), bounded length."""
|
|
36
|
+
return _CONTROL_CHARS.sub(" ", text)[:limit]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Api:
|
|
40
|
+
def __init__(self, url: str, api_key: str, transport: Transport, logger: logging.Logger) -> None:
|
|
41
|
+
self.url = url
|
|
42
|
+
self._api_key = api_key
|
|
43
|
+
self._transport = transport
|
|
44
|
+
self._log = logger
|
|
45
|
+
parts = urlsplit(url)
|
|
46
|
+
self._display_url = urlunsplit(parts._replace(netloc=parts.netloc.rpartition("@")[2]))
|
|
47
|
+
|
|
48
|
+
def warn_status(self, label: str, path: str, res: Response, suffix: str = "") -> None:
|
|
49
|
+
if 300 <= res.status < 400:
|
|
50
|
+
if res.location:
|
|
51
|
+
target = clean(urljoin(self.url + path, res.location))
|
|
52
|
+
moved_to = target[: -len(path)] if target.endswith(path) else target
|
|
53
|
+
self._log.warning(
|
|
54
|
+
"%s -> %d: ACP moved to %s; update your url (currently %s). Redirects are not "
|
|
55
|
+
"followed, so the API key is never sent to another host.%s",
|
|
56
|
+
label, res.status, moved_to, self._display_url, suffix,
|
|
57
|
+
) # fmt: skip
|
|
58
|
+
else:
|
|
59
|
+
self._log.warning(
|
|
60
|
+
"%s -> %d: redirect without a Location; check your url.%s", label, res.status, suffix
|
|
61
|
+
)
|
|
62
|
+
elif res.status == 401:
|
|
63
|
+
self._log.warning(
|
|
64
|
+
"%s -> 401: ACP rejected the API key. Check ACP_API_KEY is the full key shown once when it "
|
|
65
|
+
"was created (not revoked); create a new key on the app page if unsure.%s",
|
|
66
|
+
label, suffix,
|
|
67
|
+
) # fmt: skip
|
|
68
|
+
else:
|
|
69
|
+
self._log.warning("%s -> %d%s", label, res.status, suffix)
|
|
70
|
+
|
|
71
|
+
def post(self, path: str, body: bytes, timeout: float) -> tuple[bool, Any]:
|
|
72
|
+
"""Fail-open POST: (accepted, parsed JSON reply or None). Never raises."""
|
|
73
|
+
try:
|
|
74
|
+
res = self._transport(
|
|
75
|
+
Request("POST", self.url + path, body, {"Content-Type": "application/json"}, timeout)
|
|
76
|
+
)
|
|
77
|
+
except Exception as err:
|
|
78
|
+
self._log.warning("%s failed (continuing anyway): %s", path, err)
|
|
79
|
+
return False, None
|
|
80
|
+
if not 200 <= res.status < 300:
|
|
81
|
+
self.warn_status(path, path, res)
|
|
82
|
+
return False, None
|
|
83
|
+
try:
|
|
84
|
+
return True, json.loads(res.body) if res.body else None
|
|
85
|
+
except ValueError:
|
|
86
|
+
return True, None
|
|
87
|
+
|
|
88
|
+
def get_json(self, label: str, path: str, timeout: float, suffix: str) -> Any:
|
|
89
|
+
"""GET with the API key header; the parsed JSON reply, or None (warned). Never raises."""
|
|
90
|
+
try:
|
|
91
|
+
res = self._transport(
|
|
92
|
+
Request("GET", self.url + path, None, {"x-api-key": self._api_key}, timeout)
|
|
93
|
+
)
|
|
94
|
+
if 200 <= res.status < 300:
|
|
95
|
+
return json.loads(res.body)
|
|
96
|
+
self.warn_status(label, path, res, suffix)
|
|
97
|
+
except Exception as err:
|
|
98
|
+
self._log.warning("%s failed%s: %s", label, suffix, err)
|
|
99
|
+
return None
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Background threads for heartbeats and flushes, a time-bounded stop (also at exit), and fork safety."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import atexit
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
import weakref
|
|
11
|
+
|
|
12
|
+
from ._control import ControlState
|
|
13
|
+
from ._reporter import Reporter
|
|
14
|
+
|
|
15
|
+
_instances: weakref.WeakSet[Background] = weakref.WeakSet()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Background:
|
|
19
|
+
"""Daemon threads never keep the process alive; ``stop()`` and interpreter exit flush with a deadline."""
|
|
20
|
+
|
|
21
|
+
_control: ControlState
|
|
22
|
+
_reporter: Reporter
|
|
23
|
+
_log: logging.Logger
|
|
24
|
+
|
|
25
|
+
def __init__(self, heartbeat_seconds: float, flush_seconds: float) -> None:
|
|
26
|
+
self._heartbeat_seconds = heartbeat_seconds
|
|
27
|
+
self._flush_seconds = flush_seconds
|
|
28
|
+
self._state_lock = threading.Lock()
|
|
29
|
+
self._closing = threading.Event()
|
|
30
|
+
self._wake = threading.Event()
|
|
31
|
+
self._started = False
|
|
32
|
+
self._restart_after_fork = False
|
|
33
|
+
self._threads: list[threading.Thread] = []
|
|
34
|
+
_instances.add(self)
|
|
35
|
+
|
|
36
|
+
def _heartbeat(self) -> None:
|
|
37
|
+
raise NotImplementedError
|
|
38
|
+
|
|
39
|
+
def start(self) -> None:
|
|
40
|
+
"""Start heartbeats and periodic flushes on daemon threads (they never keep the process alive)."""
|
|
41
|
+
with self._state_lock:
|
|
42
|
+
if self._started:
|
|
43
|
+
return
|
|
44
|
+
self._started = True
|
|
45
|
+
self._closing.clear()
|
|
46
|
+
self._wake.clear()
|
|
47
|
+
self._threads = [
|
|
48
|
+
threading.Thread(target=self._heartbeat_loop, name="acp-heartbeat", daemon=True),
|
|
49
|
+
threading.Thread(target=self._flush_loop, name="acp-flush", daemon=True),
|
|
50
|
+
]
|
|
51
|
+
for thread in self._threads:
|
|
52
|
+
thread.start()
|
|
53
|
+
atexit.register(self._atexit)
|
|
54
|
+
|
|
55
|
+
def stop(self, timeout: float = 10.0) -> None:
|
|
56
|
+
"""Stop the background threads and send what is buffered, giving up after ``timeout`` seconds."""
|
|
57
|
+
deadline = time.monotonic() + timeout
|
|
58
|
+
with self._state_lock:
|
|
59
|
+
self._started = False
|
|
60
|
+
threads, self._threads = self._threads, []
|
|
61
|
+
self._closing.set()
|
|
62
|
+
self._wake.set()
|
|
63
|
+
atexit.unregister(self._atexit)
|
|
64
|
+
# On a helper thread so that nothing (a hung connection, a slow DNS lookup) holds stop() or the
|
|
65
|
+
# interpreter's exit past the timeout.
|
|
66
|
+
flushed: list[bool] = []
|
|
67
|
+
finisher = threading.Thread(
|
|
68
|
+
target=lambda: flushed.append(self._reporter.flush(deadline)), daemon=True
|
|
69
|
+
)
|
|
70
|
+
try:
|
|
71
|
+
finisher.start()
|
|
72
|
+
except RuntimeError: # threads can no longer be started (interpreter shutting down)
|
|
73
|
+
flushed.append(self._reporter.flush(deadline))
|
|
74
|
+
else:
|
|
75
|
+
finisher.join(max(0.0, deadline - time.monotonic()))
|
|
76
|
+
if flushed != [True]:
|
|
77
|
+
self._log.warning("stop: buffered runs could not be sent within %.1f s", timeout)
|
|
78
|
+
for thread in threads:
|
|
79
|
+
if thread is not threading.current_thread():
|
|
80
|
+
thread.join(max(0.0, min(1.0, deadline - time.monotonic())))
|
|
81
|
+
|
|
82
|
+
def flush(self) -> None:
|
|
83
|
+
"""Send buffered runs now (blocking). Never raises."""
|
|
84
|
+
self._reporter.flush()
|
|
85
|
+
|
|
86
|
+
def _atexit(self) -> None:
|
|
87
|
+
self.stop(timeout=5.0)
|
|
88
|
+
|
|
89
|
+
def _after_fork_in_child(self) -> None:
|
|
90
|
+
self._state_lock = threading.Lock()
|
|
91
|
+
self._closing = threading.Event()
|
|
92
|
+
self._wake = threading.Event()
|
|
93
|
+
self._control.reset_lock_after_fork()
|
|
94
|
+
self._reporter.reset_after_fork()
|
|
95
|
+
self._restart_after_fork = self._started
|
|
96
|
+
self._started = False
|
|
97
|
+
self._threads = []
|
|
98
|
+
|
|
99
|
+
def _resume_after_fork(self) -> None:
|
|
100
|
+
if self._restart_after_fork:
|
|
101
|
+
self._restart_after_fork = False
|
|
102
|
+
self.start()
|
|
103
|
+
|
|
104
|
+
def _heartbeat_loop(self) -> None:
|
|
105
|
+
while True:
|
|
106
|
+
self._heartbeat()
|
|
107
|
+
if self._closing.wait(self._heartbeat_seconds):
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
def _flush_loop(self) -> None:
|
|
111
|
+
while not self._closing.is_set():
|
|
112
|
+
self._wake.wait(self._flush_seconds)
|
|
113
|
+
self._wake.clear()
|
|
114
|
+
if self._closing.is_set():
|
|
115
|
+
return
|
|
116
|
+
self._reporter.flush()
|
|
117
|
+
|
|
118
|
+
def _wake_flusher(self) -> None:
|
|
119
|
+
if any(t.is_alive() for t in self._threads):
|
|
120
|
+
self._wake.set()
|
|
121
|
+
else:
|
|
122
|
+
threading.Thread(target=self._reporter.flush, name="acp-flush-once", daemon=True).start()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _after_fork_in_child() -> None:
|
|
126
|
+
for instance in list(_instances):
|
|
127
|
+
instance._after_fork_in_child()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
if hasattr(os, "register_at_fork"):
|
|
131
|
+
os.register_at_fork(after_in_child=_after_fork_in_child)
|