power-loop 5.2.2__tar.gz → 5.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {power_loop-5.2.2 → power_loop-5.4.0}/PKG-INFO +1 -1
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/__init__.py +1 -1
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/_vendor/llm_client/interface.py +5 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/_vendor/llm_client/llm_factory.py +15 -2
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/runtime/env.py +4 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/runtime/provider.py +1 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/runtime/representation.py +17 -6
- power_loop-5.4.0/power_loop/tools/command_policy.py +201 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/tools/default_manifest.py +8 -5
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/tools/default_tools.py +51 -3
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop.egg-info/PKG-INFO +1 -1
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop.egg-info/SOURCES.txt +1 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/LICENSE +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/README.md +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/_vendor/__init__.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/_vendor/llm_client/__init__.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/_vendor/llm_client/anthropic_factory.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/_vendor/llm_client/capabilities.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/_vendor/llm_client/llm_tooling.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/_vendor/llm_client/llm_utils.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/_vendor/llm_client/multimodal.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/agent/__init__.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/agent/follow_up.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/agent/sink.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/agent/stateful_loop.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/agent/system_prompt.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/agent/types.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/contracts/__init__.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/contracts/errors.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/contracts/event_payloads.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/contracts/events.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/contracts/handlers.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/contracts/hook_contexts.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/contracts/hooks.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/contracts/messages.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/contracts/protocols.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/contracts/tools.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/contrib/__init__.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/contrib/_redact.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/contrib/jsonl_sink.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/contrib/logging_sink.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/contrib/mcp.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/contrib/metrics_sink.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/contrib/otel_sink.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/core/agent_context.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/core/events.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/core/hooks.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/core/phase.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/core/pipeline.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/core/runner.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/core/state.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/py.typed +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/runtime/blackboard.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/runtime/budget.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/runtime/cancellation.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/runtime/compact.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/runtime/exec_backend.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/runtime/fold.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/runtime/fold_adapter.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/runtime/history_projector.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/runtime/history_sanitize.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/runtime/human_input.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/runtime/memory.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/runtime/notes.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/runtime/retry.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/runtime/runtime_state.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/runtime/session_store.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/runtime/skills.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/runtime/spec.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/runtime/store/__init__.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/runtime/store/backends/__init__.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/runtime/store/backends/mysql.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/runtime/store/backends/postgres.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/runtime/store/backends/sqlite.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/runtime/store/capabilities.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/runtime/store/db.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/runtime/store/dialect.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/runtime/store/factory.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/runtime/store/schema.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/runtime/store/store.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/runtime/store/types.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/runtime/structured.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/runtime/stub_provider.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/runtime/timers.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/tools/__init__.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/tools/blackboard.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/tools/registry.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/tools/spawn_agent.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/workflow/__init__.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/workflow/api.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/workflow/engine.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/workflow/introspect.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/workflow/journal.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/workflow/result.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/workflow/resume.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/workflow/runner.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/workflow/spec.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/workflow/subprocess_executor.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/workflow/tool.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop/workflow/worker.py +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop.egg-info/dependency_links.txt +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop.egg-info/requires.txt +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/power_loop.egg-info/top_level.txt +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/pyproject.toml +0 -0
- {power_loop-5.2.2 → power_loop-5.4.0}/setup.cfg +0 -0
|
@@ -15,7 +15,7 @@ Stability tiers
|
|
|
15
15
|
无版本承诺,可随时变更或删除。
|
|
16
16
|
"""
|
|
17
17
|
|
|
18
|
-
__version__ = "5.
|
|
18
|
+
__version__ = "5.4.0"
|
|
19
19
|
|
|
20
20
|
# Public LLM contract (SDK-free) re-exported so callers (e.g. writing llm.* hooks or
|
|
21
21
|
# a custom LLMService) don't reach into the internal vendored transport package (H3.4).
|
|
@@ -467,6 +467,11 @@ class OpenAICompatibleChatConfig:
|
|
|
467
467
|
stream_max_restarts: int = 0
|
|
468
468
|
stream_resume_instruction: str = "继续,从你上次中断的位置继续输出。不要重复已经输出的内容。"
|
|
469
469
|
capability_overrides: dict[str, Any] = field(default_factory=dict)
|
|
470
|
+
# Config-level request kwargs merged under every request's ``extra`` (request wins; nested
|
|
471
|
+
# ``extra_body`` dicts are merged key-by-key). This is how a host turns a per-model switch
|
|
472
|
+
# (e.g. DashScope ``extra_body.enable_thinking``) on for a whole loop without touching the
|
|
473
|
+
# pipeline's per-call LLMRequest.
|
|
474
|
+
request_extra: dict[str, Any] = field(default_factory=dict)
|
|
470
475
|
|
|
471
476
|
@property
|
|
472
477
|
def is_ready(self) -> bool:
|
|
@@ -82,6 +82,19 @@ def _format_messages_for_debug(messages: Sequence[dict[str, Any]]) -> str:
|
|
|
82
82
|
|
|
83
83
|
|
|
84
84
|
|
|
85
|
+
def merge_request_extra(base: dict[str, Any] | None, override: dict[str, Any] | None) -> dict[str, Any]:
|
|
86
|
+
"""Config-level ``request_extra`` under a request's ``extra`` (request wins; ``extra_body`` merged)."""
|
|
87
|
+
out: dict[str, Any] = dict(base or {})
|
|
88
|
+
for k, v in dict(override or {}).items():
|
|
89
|
+
if k == "extra_body" and isinstance(v, dict) and isinstance(out.get("extra_body"), dict):
|
|
90
|
+
out["extra_body"] = {**out["extra_body"], **v}
|
|
91
|
+
else:
|
|
92
|
+
out[k] = v
|
|
93
|
+
if isinstance(out.get("extra_body"), dict):
|
|
94
|
+
out["extra_body"] = dict(out["extra_body"])
|
|
95
|
+
return out
|
|
96
|
+
|
|
97
|
+
|
|
85
98
|
class OpenAICompatibleChatLLMService(LLMService):
|
|
86
99
|
"""
|
|
87
100
|
Minimal OpenAI-compatible chat completion client.
|
|
@@ -445,12 +458,12 @@ class OpenAICompatibleChatLLMService(LLMService):
|
|
|
445
458
|
await asyncio.sleep(delay)
|
|
446
459
|
raise cast(Exception, last_err)
|
|
447
460
|
|
|
448
|
-
def _request_kwargs(self, request: LLMRequest) -> dict[str, Any]:
|
|
461
|
+
def _request_kwargs(self, request: LLMRequest) -> dict[str, Any]: # noqa: C901
|
|
449
462
|
"""
|
|
450
463
|
Map `LLMRequest` into kwargs for OpenAI-compatible chat.completions.create.
|
|
451
464
|
Falls back to self._cfg for default settings.
|
|
452
465
|
"""
|
|
453
|
-
out: dict[str, Any] =
|
|
466
|
+
out: dict[str, Any] = merge_request_extra(getattr(self._cfg, "request_extra", None), request.extra)
|
|
454
467
|
out["model"] = request.model if request.model else self._cfg.model
|
|
455
468
|
|
|
456
469
|
req_temp = request.temperature if request.temperature is not None else self._cfg.temperature
|
|
@@ -35,6 +35,10 @@ class RuntimeEnv:
|
|
|
35
35
|
# conversation-scoped / cross-process board. See runtime.blackboard.
|
|
36
36
|
blackboard: Blackboard | None = None
|
|
37
37
|
blackboard_id: str | None = None
|
|
38
|
+
# Command categories the host forbids for `bash` / `background_run` (see
|
|
39
|
+
# tools.command_policy): "package_install" | "download" | "daemon". "pipe_to_shell"
|
|
40
|
+
# is always blocked. Empty (default) = only the always-blocked set applies.
|
|
41
|
+
blocked_command_categories: frozenset[str] = frozenset()
|
|
38
42
|
|
|
39
43
|
@classmethod
|
|
40
44
|
def from_env(
|
|
@@ -167,6 +167,7 @@ class LLMProviderConfig:
|
|
|
167
167
|
temperature=self.temperature,
|
|
168
168
|
max_retries=self.max_retries,
|
|
169
169
|
capability_overrides=dict(self.capability_overrides),
|
|
170
|
+
request_extra=dict(self.extra.get("request_extra") or {}),
|
|
170
171
|
)
|
|
171
172
|
|
|
172
173
|
def to_anthropic(self) -> AnthropicChatConfig:
|
|
@@ -274,10 +274,10 @@ class ProjectedRepresentation:
|
|
|
274
274
|
# Tool results keyed by tool_call_id, preserving ORDER and DUPLICATES (a multimap of FIFO
|
|
275
275
|
# queues): a malformed/imported/resumed transcript can repeat or omit an id, so a plain
|
|
276
276
|
# dict would silently collapse two results onto one id (and drop the other).
|
|
277
|
-
results: dict[str, deque[str | None]] = {}
|
|
277
|
+
results: dict[str, deque[tuple[str | None, int | None]]] = {}
|
|
278
278
|
for r in send_rows:
|
|
279
279
|
if r.role == "tool":
|
|
280
|
-
results.setdefault(r.tool_call_id or "", deque()).append(r.content)
|
|
280
|
+
results.setdefault(r.tool_call_id or "", deque()).append((r.content, r.seq))
|
|
281
281
|
tools: list[dict[str, Any]] = []
|
|
282
282
|
inputs: list[str | None] = []
|
|
283
283
|
final_text: str | None = None
|
|
@@ -293,7 +293,7 @@ class ProjectedRepresentation:
|
|
|
293
293
|
if r.role == "user":
|
|
294
294
|
if seen_assistant:
|
|
295
295
|
# Verbatim like the input (conversation content, not tool output).
|
|
296
|
-
tools.append({"name": "__user__", "text": r.content})
|
|
296
|
+
tools.append({"name": "__user__", "text": r.content, "seq": r.seq})
|
|
297
297
|
else:
|
|
298
298
|
inputs.append(r.content)
|
|
299
299
|
continue
|
|
@@ -308,8 +308,17 @@ class ProjectedRepresentation:
|
|
|
308
308
|
name = fn.get("name") or tc.get("name")
|
|
309
309
|
args = _parse_args(fn.get("arguments"))
|
|
310
310
|
bucket = results.get(str(tc.get("id") or ""))
|
|
311
|
-
result = bucket.popleft() if bucket else _NO_RESULT
|
|
312
|
-
|
|
311
|
+
result, result_seq = bucket.popleft() if bucket else (_NO_RESULT, None)
|
|
312
|
+
entry = self._project_tool(name, args, result, tool_registry)
|
|
313
|
+
# Row coordinates (5.4.0): every projected tool entry carries the pl_messages seq of
|
|
314
|
+
# its RESULT row (``seq``) and of the assistant row that issued the call
|
|
315
|
+
# (``call_seq``), so a host renderer can print a self-contained
|
|
316
|
+
# "recall_send(send_index=N, seq=S)" pointer on each line — the model should never
|
|
317
|
+
# have to scan other sends to find where a dropped body lives.
|
|
318
|
+
if isinstance(entry, dict):
|
|
319
|
+
entry.setdefault("seq", result_seq)
|
|
320
|
+
entry.setdefault("call_seq", r.seq)
|
|
321
|
+
tools.append(entry)
|
|
313
322
|
seqs = [r.seq for r in send_rows]
|
|
314
323
|
rows: list[ProjectedRow] = []
|
|
315
324
|
if inputs:
|
|
@@ -462,7 +471,9 @@ class ProjectedRepresentation:
|
|
|
462
471
|
# A mid-send user injection (reminder / finalize / steering) at its
|
|
463
472
|
# chronological position in the timeline — not a tool call.
|
|
464
473
|
return f"[user] {t.get('text') or ''}"
|
|
465
|
-
|
|
474
|
+
# Row coordinates (seq / call_seq, 5.4.0) are bookkeeping for host renderers, not part of
|
|
475
|
+
# the generic "name(k=v, …)" line — a host that wants a recall pointer overrides the render.
|
|
476
|
+
rest = {k: v for k, v in (t or {}).items() if k not in ("name", "seq", "call_seq")}
|
|
466
477
|
if not rest:
|
|
467
478
|
return str(name)
|
|
468
479
|
body = self.render_config.tool_arg_sep.join(f"{k}={v}" for k, v in rest.items())
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Category-level command policy for ``bash`` / ``background_run`` (rule layer).
|
|
2
|
+
|
|
3
|
+
`_dangerous_command_reason` in default_tools is the *absolute* deny-list (sudo, mkfs, rm -rf /…).
|
|
4
|
+
This module is the layer above it: it classifies a shell command into coarse **categories** —
|
|
5
|
+
installing packages, downloading files, piping a download into an interpreter, starting daemons —
|
|
6
|
+
and the host decides per agent which categories are blocked (``RuntimeEnv.blocked_command_categories``).
|
|
7
|
+
|
|
8
|
+
Why a category layer and not one big regex: a sandbox that can reach npm/pypi through the egress
|
|
9
|
+
proxy *will* have a model install playwright + chromium the first time a skill points it at a
|
|
10
|
+
tool it doesn't have (that is exactly what happened, and it cost 20 minutes and a few hundred MB).
|
|
11
|
+
The fix is not to blacklist "playwright" — it is to make "install software" a capability the
|
|
12
|
+
operator grants, with a refusal message that tells the model what to do instead.
|
|
13
|
+
|
|
14
|
+
Lexical only, no semantics: we split on shell operators (shlex with punctuation_chars) and look at
|
|
15
|
+
each simple command's program name + subcommand. False negatives are possible (``python -c
|
|
16
|
+
"import subprocess; …"``); that is what the host's optional LLM review layer is for. False
|
|
17
|
+
positives are cheap: the model gets a tool result telling it the category and the exit.
|
|
18
|
+
|
|
19
|
+
Library default: nothing blocked except ``pipe_to_shell`` (there is no legitimate use for
|
|
20
|
+
``curl … | sh`` inside an agent sandbox). Hosts opt in to more.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import re
|
|
25
|
+
import shlex
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
CATEGORIES: tuple[str, ...] = ("package_install", "download", "pipe_to_shell", "daemon")
|
|
29
|
+
ALWAYS_BLOCKED: frozenset[str] = frozenset({"pipe_to_shell"})
|
|
30
|
+
|
|
31
|
+
_OPERATORS = {"|", "||", "&&", ";", "&", "(", ")", ";;", "|&"}
|
|
32
|
+
_INTERPRETERS = {"sh", "bash", "zsh", "dash", "ksh", "python", "python3", "node", "perl", "ruby", "php"}
|
|
33
|
+
_WRAPPERS = {"env", "nice", "time", "command", "exec", "builtin", "nohup", "setsid", "stdbuf", "timeout"}
|
|
34
|
+
|
|
35
|
+
_NODE_PM = {"npm", "pnpm", "yarn", "bun"}
|
|
36
|
+
_NODE_PM_INSTALL = {"i", "install", "add", "ci", "update", "upgrade", "up", "dlx", "exec"}
|
|
37
|
+
_PY_PM = {"pip", "pip3", "pipx", "poetry", "uv", "conda", "mamba", "pipenv"}
|
|
38
|
+
_PY_PM_INSTALL = {"install", "add", "sync", "download"}
|
|
39
|
+
_SYS_PM = {"apt", "apt-get", "apk", "yum", "dnf", "brew", "pacman", "zypper", "snap"}
|
|
40
|
+
_SYS_PM_INSTALL = {"install", "add", "reinstall", "upgrade", "dist-upgrade"}
|
|
41
|
+
_LANG_PM = {"cargo": {"install"}, "gem": {"install"}, "go": {"install", "get"}, "cpan": {"install"}}
|
|
42
|
+
_DAEMON_PROGS = {"systemctl", "service", "daemonize", "pm2", "forever", "supervisord", "supervisorctl",
|
|
43
|
+
"screen", "tmux", "start-stop-daemon"}
|
|
44
|
+
_SERVER_PROGS = {"node", "python", "python3", "npm", "npx", "pnpm", "yarn", "bun", "deno", "serve",
|
|
45
|
+
"http-server", "uvicorn", "gunicorn", "flask", "ruby", "php", "caddy", "nginx"}
|
|
46
|
+
|
|
47
|
+
_PIPE_TO_SHELL_RE = re.compile(
|
|
48
|
+
r"(?:\bcurl\b|\bwget\b)[^|\n]*\|\s*(?:sudo\s+)?(?:sh|bash|zsh|dash|ksh|python3?|node|perl|ruby)\b"
|
|
49
|
+
r"|<\(\s*(?:curl|wget)\b"
|
|
50
|
+
r"|\b(?:sh|bash|zsh|python3?|node)\s+-c\s+[\"']?\$\(\s*(?:curl|wget)\b",
|
|
51
|
+
re.I,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _segments(command: str) -> list[list[str]]:
|
|
56
|
+
"""Split a shell command into simple commands (token lists) on operators."""
|
|
57
|
+
try:
|
|
58
|
+
lexer = shlex.shlex(command, posix=True, punctuation_chars=True)
|
|
59
|
+
lexer.whitespace_split = True
|
|
60
|
+
lexer.commenters = ""
|
|
61
|
+
tokens = list(lexer)
|
|
62
|
+
except ValueError:
|
|
63
|
+
tokens = command.split()
|
|
64
|
+
segs: list[list[str]] = []
|
|
65
|
+
cur: list[str] = []
|
|
66
|
+
for tok in tokens:
|
|
67
|
+
if tok in _OPERATORS or tok == "\n":
|
|
68
|
+
if cur:
|
|
69
|
+
segs.append(cur)
|
|
70
|
+
cur = []
|
|
71
|
+
if tok == "&":
|
|
72
|
+
segs.append(["&"]) # marker: previous segment was backgrounded
|
|
73
|
+
else:
|
|
74
|
+
cur.append(tok)
|
|
75
|
+
if cur:
|
|
76
|
+
segs.append(cur)
|
|
77
|
+
return segs
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _program(seg: list[str]) -> tuple[str, list[str]]:
|
|
81
|
+
"""Program basename + remaining args, skipping ``FOO=bar`` assignments and wrappers."""
|
|
82
|
+
i = 0
|
|
83
|
+
while i < len(seg):
|
|
84
|
+
tok = seg[i]
|
|
85
|
+
if re.match(r"^[A-Za-z_][A-Za-z0-9_]*=", tok):
|
|
86
|
+
i += 1
|
|
87
|
+
continue
|
|
88
|
+
name = Path(tok).name
|
|
89
|
+
if name in _WRAPPERS:
|
|
90
|
+
i += 1
|
|
91
|
+
# `timeout 30 cmd`, `nice -n 5 cmd`: skip their own option/value tokens
|
|
92
|
+
while i < len(seg) and (seg[i].startswith("-") or re.fullmatch(r"\d+[smhd]?", seg[i])):
|
|
93
|
+
i += 1
|
|
94
|
+
continue
|
|
95
|
+
return name, seg[i + 1:]
|
|
96
|
+
return "", []
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _subcommand(args: list[str]) -> str:
|
|
100
|
+
for a in args:
|
|
101
|
+
if not a.startswith("-"):
|
|
102
|
+
return a
|
|
103
|
+
return ""
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def classify_command(command: str) -> set[str]:
|
|
107
|
+
"""Return the set of policy categories a command touches (empty = plain)."""
|
|
108
|
+
cats: set[str] = set()
|
|
109
|
+
if not command or not command.strip():
|
|
110
|
+
return cats
|
|
111
|
+
if _PIPE_TO_SHELL_RE.search(command):
|
|
112
|
+
cats.add("pipe_to_shell")
|
|
113
|
+
segs = _segments(command)
|
|
114
|
+
for idx, seg in enumerate(segs):
|
|
115
|
+
if seg == ["&"]:
|
|
116
|
+
continue
|
|
117
|
+
prog, args = _program(seg)
|
|
118
|
+
if not prog:
|
|
119
|
+
continue
|
|
120
|
+
sub = _subcommand(args)
|
|
121
|
+
backgrounded = idx + 1 < len(segs) and segs[idx + 1] == ["&"]
|
|
122
|
+
|
|
123
|
+
# ── package_install ──
|
|
124
|
+
if prog in _NODE_PM and (sub in _NODE_PM_INSTALL or (prog == "yarn" and not sub)):
|
|
125
|
+
cats.add("package_install")
|
|
126
|
+
elif prog == "npx" or prog == "corepack":
|
|
127
|
+
cats.add("package_install")
|
|
128
|
+
elif prog in _PY_PM:
|
|
129
|
+
subs = {a for a in args if not a.startswith("-")}
|
|
130
|
+
if subs & _PY_PM_INSTALL or (prog == "uv" and "pip" in subs and "install" in subs):
|
|
131
|
+
cats.add("package_install")
|
|
132
|
+
elif prog.startswith("python") and "-m" in args:
|
|
133
|
+
m = args.index("-m")
|
|
134
|
+
if m + 1 < len(args) and args[m + 1] in {"pip", "ensurepip"} and "install" in args:
|
|
135
|
+
cats.add("package_install")
|
|
136
|
+
elif prog in _SYS_PM and sub in _SYS_PM_INSTALL:
|
|
137
|
+
cats.add("package_install")
|
|
138
|
+
elif prog in _LANG_PM and sub in _LANG_PM[prog]:
|
|
139
|
+
cats.add("package_install")
|
|
140
|
+
elif prog == "playwright" and sub == "install":
|
|
141
|
+
cats.add("package_install")
|
|
142
|
+
|
|
143
|
+
# ── download ──
|
|
144
|
+
if prog == "curl":
|
|
145
|
+
if any(a in {"-o", "-O", "--output", "--remote-name", "-J", "--remote-header-name"}
|
|
146
|
+
or a.startswith(("-o", "--output=")) or re.fullmatch(r"-[a-zA-Z]*[oO][a-zA-Z]*", a)
|
|
147
|
+
for a in args):
|
|
148
|
+
cats.add("download")
|
|
149
|
+
elif prog == "wget":
|
|
150
|
+
to_stdout = any(a in {"-O-", "-qO-", "-O", "--output-document=-"} and (a.endswith("-") or
|
|
151
|
+
(a == "-O" and "-" in args[args.index(a) + 1:args.index(a) + 2]))
|
|
152
|
+
for a in args)
|
|
153
|
+
if not to_stdout:
|
|
154
|
+
cats.add("download")
|
|
155
|
+
elif prog in {"aria2c", "axel"}:
|
|
156
|
+
cats.add("download")
|
|
157
|
+
elif prog == "git" and sub in {"clone", "fetch", "pull"}:
|
|
158
|
+
cats.add("download")
|
|
159
|
+
|
|
160
|
+
# ── daemon ──
|
|
161
|
+
if prog in _DAEMON_PROGS:
|
|
162
|
+
cats.add("daemon")
|
|
163
|
+
elif backgrounded and prog in _SERVER_PROGS:
|
|
164
|
+
cats.add("daemon")
|
|
165
|
+
# wrappers that only exist to daemonize
|
|
166
|
+
if seg and Path(seg[0]).name in {"nohup", "setsid"}:
|
|
167
|
+
cats.add("daemon")
|
|
168
|
+
return cats
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
_EXITS = {
|
|
172
|
+
"package_install": (
|
|
173
|
+
"installing packages (npm/pip/apt/cargo…) is disabled for this agent. Use what is preinstalled "
|
|
174
|
+
"in the sandbox; if something is genuinely missing, tell the user so the platform can add it. "
|
|
175
|
+
"For screenshots of HTML prototypes use the render_html tool, not a browser install."
|
|
176
|
+
),
|
|
177
|
+
"download": (
|
|
178
|
+
"downloading files with curl/wget/git clone is disabled for this agent. Use the platform's "
|
|
179
|
+
"fetch_file / web_read tools for content you need, or ask the user to provide the file."
|
|
180
|
+
),
|
|
181
|
+
"pipe_to_shell": (
|
|
182
|
+
"piping a download straight into a shell/interpreter (curl … | sh) is never allowed."
|
|
183
|
+
),
|
|
184
|
+
"daemon": (
|
|
185
|
+
"starting long-running/background daemons is disabled for this agent. Run the command in the "
|
|
186
|
+
"foreground with a timeout, or use the background_run tool for a bounded job."
|
|
187
|
+
),
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def command_policy_reason(command: str, blocked: frozenset[str] | set[str] | None) -> str | None:
|
|
192
|
+
"""Return a refusal message if ``command`` touches a blocked category, else None."""
|
|
193
|
+
effective = set(ALWAYS_BLOCKED) | set(blocked or ())
|
|
194
|
+
hit = classify_command(command) & effective
|
|
195
|
+
if not hit:
|
|
196
|
+
return None
|
|
197
|
+
cat = next(c for c in CATEGORIES if c in hit) # stable, most-severe-first order
|
|
198
|
+
return f"Error: Command blocked by sandbox policy ({cat}): {_EXITS[cat]}"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
__all__ = ["ALWAYS_BLOCKED", "CATEGORIES", "classify_command", "command_policy_reason"]
|
|
@@ -277,15 +277,18 @@ DEFAULT_TOOL_DEFINITIONS: list[ToolDefinition] = [
|
|
|
277
277
|
ToolDefinition(
|
|
278
278
|
name="recall_send",
|
|
279
279
|
description=(
|
|
280
|
-
"Re-expand an earlier send shown in your context only as a compact summary.
|
|
281
|
-
"
|
|
282
|
-
"
|
|
283
|
-
"
|
|
280
|
+
"Re-expand an earlier send shown in your context only as a compact summary. Each "
|
|
281
|
+
"summarized tool line carries a coordinate «#N·sS» (send_index N, row seq S). To get "
|
|
282
|
+
"ONE original tool result in full (a file you read, a skill you loaded, a vision answer, "
|
|
283
|
+
"a command's output), call recall_send(send_index=N, seq=S) — that returns just that row "
|
|
284
|
+
"with a large cap. Without seq it lists every row of send #N (each body cut at 2000 "
|
|
285
|
+
"chars) — use that only to see what happened, not to fetch a body. Read-only, current session."
|
|
284
286
|
),
|
|
285
287
|
input_schema={
|
|
286
288
|
"type": "object",
|
|
287
289
|
"properties": {
|
|
288
|
-
"send_index": {"type": "integer", "description": "The #N of the past send
|
|
290
|
+
"send_index": {"type": "integer", "description": "The #N of the past send."},
|
|
291
|
+
"seq": {"type": "integer", "description": "Optional: the sS row coordinate from a summary line → return that single row in full."},
|
|
289
292
|
},
|
|
290
293
|
"required": ["send_index"],
|
|
291
294
|
},
|
|
@@ -23,6 +23,7 @@ from power_loop.runtime.exec_backend import DEFAULT_SHELL_BACKEND, ShellBackend
|
|
|
23
23
|
from power_loop.runtime.human_input import request_user_input
|
|
24
24
|
from power_loop.runtime.runtime_state import get_tool_runtime_context
|
|
25
25
|
from power_loop.runtime.skills import get_default_loader
|
|
26
|
+
from power_loop.tools.command_policy import command_policy_reason
|
|
26
27
|
|
|
27
28
|
logger = logging.getLogger(__name__)
|
|
28
29
|
|
|
@@ -531,6 +532,9 @@ class BashSession:
|
|
|
531
532
|
reason = _dangerous_command_reason(command)
|
|
532
533
|
if reason:
|
|
533
534
|
return f"Error: Dangerous command blocked ({reason}). Run it manually if you really intend it."
|
|
535
|
+
policy_err = command_policy_reason(command, get_runtime_env().blocked_command_categories)
|
|
536
|
+
if policy_err:
|
|
537
|
+
return policy_err
|
|
534
538
|
|
|
535
539
|
with self._lock:
|
|
536
540
|
if self._proc is None or self._proc.poll() is not None:
|
|
@@ -1220,6 +1224,9 @@ class BackgroundManager:
|
|
|
1220
1224
|
reason = _dangerous_command_reason(command)
|
|
1221
1225
|
if reason:
|
|
1222
1226
|
return f"Error: Dangerous command blocked ({reason}). Run it manually if you really intend it."
|
|
1227
|
+
policy_err = command_policy_reason(command, get_runtime_env().blocked_command_categories)
|
|
1228
|
+
if policy_err:
|
|
1229
|
+
return policy_err
|
|
1223
1230
|
|
|
1224
1231
|
scope_err = _validate_bash_command_scope(command)
|
|
1225
1232
|
if scope_err:
|
|
@@ -1689,16 +1696,21 @@ async def run_recall_compacted(
|
|
|
1689
1696
|
# ── recall_send: re-expand a send the projection layer summarized ──────────────
|
|
1690
1697
|
|
|
1691
1698
|
RECALL_SEND_CONTENT_CHARS = 2000
|
|
1699
|
+
# Single-row recall (``recall_send(send_index, seq=…)``) returns the ORIGINAL body — a skill file,
|
|
1700
|
+
# a read file, a vision answer — so its cap is generous. The send-level view keeps the small cap
|
|
1701
|
+
# because it lists every row of the send.
|
|
1702
|
+
RECALL_SEND_ROW_CHARS = 40_000
|
|
1692
1703
|
|
|
1693
1704
|
|
|
1694
|
-
async def run_recall_send(send_index: int) -> str:
|
|
1705
|
+
async def run_recall_send(send_index: int, seq: int | None = None) -> str:
|
|
1695
1706
|
"""Re-expand one past send the send-context projection summarized.
|
|
1696
1707
|
|
|
1697
1708
|
When ``AgentLoopConfig.history_projector`` is set, finished sends appear in context as a
|
|
1698
1709
|
compact projected summary while their FULL detail stays in ``pl_messages`` (the immutable
|
|
1699
1710
|
audit). This returns that send's original messages — assistant text, tool calls (by name)
|
|
1700
1711
|
and their results — read-only, current session, by the ``send_index`` (the ``#N`` the
|
|
1701
|
-
summary shows).
|
|
1712
|
+
summary shows). With ``seq`` it returns just THAT row (the ``sS`` coordinate a projected
|
|
1713
|
+
tool line carries) with a much larger body cap — plus the assistant call that produced it.
|
|
1702
1714
|
"""
|
|
1703
1715
|
ctx = get_tool_runtime_context(required=True)
|
|
1704
1716
|
store, sid = ctx.store, ctx.session_id
|
|
@@ -1713,6 +1725,18 @@ async def run_recall_send(send_index: int) -> str:
|
|
|
1713
1725
|
if not rows:
|
|
1714
1726
|
return f"No messages found for send #{target} in this session."
|
|
1715
1727
|
|
|
1728
|
+
if seq is not None:
|
|
1729
|
+
try:
|
|
1730
|
+
want = int(seq)
|
|
1731
|
+
except (TypeError, ValueError):
|
|
1732
|
+
return f"Invalid seq: {seq!r} (expected an integer)."
|
|
1733
|
+
hit = next((r for r in rows if r.seq == want), None)
|
|
1734
|
+
if hit is None:
|
|
1735
|
+
lo, hi = min(r.seq for r in rows), max(r.seq for r in rows)
|
|
1736
|
+
return (f"No row seq {want} in send #{target} (its rows span seq {lo}–{hi}). "
|
|
1737
|
+
"Use the exact «sS» coordinate from the summary line, or omit seq to list the whole send.")
|
|
1738
|
+
return _render_recall_row(hit, rows, cap=RECALL_SEND_ROW_CHARS, head=f"send #{target} · seq {want}")
|
|
1739
|
+
|
|
1716
1740
|
def _tc_name(tc: dict[str, Any]) -> str:
|
|
1717
1741
|
# ``function`` is normally a dict, but a malformed/imported tool_call can carry a
|
|
1718
1742
|
# non-dict (e.g. a bare string); guard so name extraction never raises (the tool
|
|
@@ -1745,6 +1769,30 @@ async def run_recall_send(send_index: int) -> str:
|
|
|
1745
1769
|
return f"send #{target} — {len(rows)} message(s):\n\n" + "\n\n".join(blocks)
|
|
1746
1770
|
|
|
1747
1771
|
|
|
1772
|
+
def _render_recall_row(hit: Any, rows: list[Any], *, cap: int, head: str) -> str:
|
|
1773
|
+
"""One original row in full (capped at ``cap`` chars, remainder counted), preceded by the
|
|
1774
|
+
assistant call that produced it when the row is a tool result."""
|
|
1775
|
+
blocks: list[str] = []
|
|
1776
|
+
if hit.role == "tool" and hit.tool_call_id:
|
|
1777
|
+
for a in rows:
|
|
1778
|
+
for tc in (a.tool_calls or []) if a.role == "assistant" else []:
|
|
1779
|
+
if str(tc.get("id") or "") == str(hit.tool_call_id):
|
|
1780
|
+
fn = tc.get("function")
|
|
1781
|
+
fn = fn if isinstance(fn, dict) else {}
|
|
1782
|
+
blocks.append(
|
|
1783
|
+
f"[seq {a.seq} · assistant call · {fn.get('name') or tc.get('name') or '?'}]\n"
|
|
1784
|
+
f"{fn.get('arguments') or ''}"
|
|
1785
|
+
)
|
|
1786
|
+
break
|
|
1787
|
+
body = hit.content or ""
|
|
1788
|
+
total = len(body)
|
|
1789
|
+
if total > cap:
|
|
1790
|
+
body = body[:cap] + f" …[truncated: {total - cap} more chars of {total}]"
|
|
1791
|
+
label = f"[seq {hit.seq} · {hit.role}" + (f" · {hit.name}" if hit.name else "") + "]"
|
|
1792
|
+
blocks.append(f"{label}\n{body}")
|
|
1793
|
+
return f"{head} — original row ({total} chars):\n\n" + "\n\n".join(blocks)
|
|
1794
|
+
|
|
1795
|
+
|
|
1748
1796
|
# Async tool handlers are registered as ``async def`` adapters (NOT sync lambdas that
|
|
1749
1797
|
# merely return a coroutine): the registry uses ``inspect.iscoroutinefunction`` to decide
|
|
1750
1798
|
# sync-vs-async dispatch and to keep the per-call ``runtime_env_context`` held across the
|
|
@@ -1776,7 +1824,7 @@ async def _h_recall_compacted(**kw: Any) -> Any:
|
|
|
1776
1824
|
|
|
1777
1825
|
|
|
1778
1826
|
async def _h_recall_send(**kw: Any) -> Any:
|
|
1779
|
-
return await run_recall_send(kw["send_index"])
|
|
1827
|
+
return await run_recall_send(kw["send_index"], kw.get("seq"))
|
|
1780
1828
|
|
|
1781
1829
|
|
|
1782
1830
|
async def _h_background_run(**kw: Any) -> Any:
|
|
@@ -84,6 +84,7 @@ power_loop/runtime/store/backends/postgres.py
|
|
|
84
84
|
power_loop/runtime/store/backends/sqlite.py
|
|
85
85
|
power_loop/tools/__init__.py
|
|
86
86
|
power_loop/tools/blackboard.py
|
|
87
|
+
power_loop/tools/command_policy.py
|
|
87
88
|
power_loop/tools/default_manifest.py
|
|
88
89
|
power_loop/tools/default_tools.py
|
|
89
90
|
power_loop/tools/registry.py
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|