power-loop 0.2.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.
- llm_client/__init__.py +0 -0
- llm_client/capabilities.py +162 -0
- llm_client/interface.py +470 -0
- llm_client/llm_factory.py +981 -0
- llm_client/llm_tooling.py +645 -0
- llm_client/llm_utils.py +205 -0
- llm_client/multimodal.py +237 -0
- llm_client/qwen_image.py +576 -0
- llm_client/web_search.py +149 -0
- power_loop/__init__.py +326 -0
- power_loop/agent/__init__.py +6 -0
- power_loop/agent/sink.py +247 -0
- power_loop/agent/stateful_loop.py +363 -0
- power_loop/agent/system_prompt.py +396 -0
- power_loop/agent/types.py +41 -0
- power_loop/contracts/__init__.py +132 -0
- power_loop/contracts/errors.py +140 -0
- power_loop/contracts/event_payloads.py +278 -0
- power_loop/contracts/events.py +86 -0
- power_loop/contracts/handlers.py +45 -0
- power_loop/contracts/hook_contexts.py +265 -0
- power_loop/contracts/hooks.py +64 -0
- power_loop/contracts/messages.py +90 -0
- power_loop/contracts/protocols.py +48 -0
- power_loop/contracts/tools.py +56 -0
- power_loop/core/agent_context.py +94 -0
- power_loop/core/events.py +124 -0
- power_loop/core/hooks.py +122 -0
- power_loop/core/phase.py +217 -0
- power_loop/core/pipeline.py +880 -0
- power_loop/core/runner.py +60 -0
- power_loop/core/state.py +208 -0
- power_loop/runtime/budget.py +179 -0
- power_loop/runtime/cancellation.py +127 -0
- power_loop/runtime/compact.py +300 -0
- power_loop/runtime/env.py +103 -0
- power_loop/runtime/memory.py +107 -0
- power_loop/runtime/provider.py +176 -0
- power_loop/runtime/retry.py +182 -0
- power_loop/runtime/session_store.py +636 -0
- power_loop/runtime/skills.py +201 -0
- power_loop/runtime/spec.py +233 -0
- power_loop/runtime/structured.py +225 -0
- power_loop/tools/__init__.py +51 -0
- power_loop/tools/default_manifest.py +244 -0
- power_loop/tools/default_tools.py +766 -0
- power_loop/tools/registry.py +162 -0
- power_loop/tools/spawn_agent.py +173 -0
- power_loop-0.2.0.dist-info/METADATA +632 -0
- power_loop-0.2.0.dist-info/RECORD +53 -0
- power_loop-0.2.0.dist-info/WHEEL +5 -0
- power_loop-0.2.0.dist-info/licenses/LICENSE +21 -0
- power_loop-0.2.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncIterator, Iterator
|
|
4
|
+
from contextlib import asynccontextmanager, contextmanager
|
|
5
|
+
|
|
6
|
+
from power_loop.core.agent_context import (
|
|
7
|
+
reset_ctx,
|
|
8
|
+
reset_event_bus,
|
|
9
|
+
reset_hooks,
|
|
10
|
+
reset_session_id,
|
|
11
|
+
set_ctx,
|
|
12
|
+
set_event_bus,
|
|
13
|
+
set_hooks,
|
|
14
|
+
set_session_id,
|
|
15
|
+
)
|
|
16
|
+
from power_loop.core.events import DEFAULT_EVENT_BUS, AgentEventBus
|
|
17
|
+
from power_loop.core.hooks import DEFAULT_HOOKS, AgentHooks
|
|
18
|
+
from power_loop.core.state import ContextManager
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AgentRunner:
|
|
22
|
+
"""Runner provides per-session isolation for event bus / hooks / state."""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
*,
|
|
27
|
+
event_bus: AgentEventBus | None = None,
|
|
28
|
+
hooks: AgentHooks | None = None,
|
|
29
|
+
) -> None:
|
|
30
|
+
self.event_bus = event_bus if event_bus is not None else DEFAULT_EVENT_BUS
|
|
31
|
+
self.hooks = hooks if hooks is not None else DEFAULT_HOOKS
|
|
32
|
+
|
|
33
|
+
@contextmanager
|
|
34
|
+
def session(self, *, session_id: str | None = None) -> Iterator[AgentRunner]:
|
|
35
|
+
tok_bus = set_event_bus(self.event_bus)
|
|
36
|
+
tok_hooks = set_hooks(self.hooks)
|
|
37
|
+
tok_ctx = set_ctx(ContextManager(role="main"))
|
|
38
|
+
tok_sid = set_session_id(session_id)
|
|
39
|
+
try:
|
|
40
|
+
yield self
|
|
41
|
+
finally:
|
|
42
|
+
reset_session_id(tok_sid)
|
|
43
|
+
reset_ctx(tok_ctx)
|
|
44
|
+
reset_hooks(tok_hooks)
|
|
45
|
+
reset_event_bus(tok_bus)
|
|
46
|
+
|
|
47
|
+
@asynccontextmanager
|
|
48
|
+
async def session_async(self, *, session_id: str | None = None) -> AsyncIterator[AgentRunner]:
|
|
49
|
+
tok_bus = set_event_bus(self.event_bus)
|
|
50
|
+
tok_hooks = set_hooks(self.hooks)
|
|
51
|
+
tok_ctx = set_ctx(ContextManager(role="main"))
|
|
52
|
+
tok_sid = set_session_id(session_id)
|
|
53
|
+
try:
|
|
54
|
+
yield self
|
|
55
|
+
finally:
|
|
56
|
+
reset_session_id(tok_sid)
|
|
57
|
+
reset_ctx(tok_ctx)
|
|
58
|
+
reset_hooks(tok_hooks)
|
|
59
|
+
reset_event_bus(tok_bus)
|
|
60
|
+
|
power_loop/core/state.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from llm_client.interface import LLMResponse
|
|
9
|
+
from power_loop.contracts.event_payloads import TodoUpdatedPayload
|
|
10
|
+
from power_loop.contracts.events import AgentEvent, AgentEventType
|
|
11
|
+
from power_loop.core.agent_context import get_event_bus, get_session_id
|
|
12
|
+
from power_loop.runtime.env import AGENT_DIR
|
|
13
|
+
from power_loop.runtime.skills import get_default_loader
|
|
14
|
+
|
|
15
|
+
TOOL_MAX_LINES = 20
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TodoManager:
|
|
19
|
+
def __init__(self) -> None:
|
|
20
|
+
self.items: list[dict[str, Any]] = []
|
|
21
|
+
|
|
22
|
+
def update(self, items: list[dict[str, Any]]) -> str:
|
|
23
|
+
if len(items) > 20:
|
|
24
|
+
raise ValueError("Max 20 todos allowed")
|
|
25
|
+
|
|
26
|
+
validated: list[dict[str, Any]] = []
|
|
27
|
+
in_progress_count = 0
|
|
28
|
+
for i, item in enumerate(items):
|
|
29
|
+
text = str(item.get("text", "")).strip()
|
|
30
|
+
status = str(item.get("status", "pending")).lower()
|
|
31
|
+
item_id = str(item.get("id", str(i + 1)))
|
|
32
|
+
if not text:
|
|
33
|
+
raise ValueError(f"Item {item_id}: text required")
|
|
34
|
+
if status not in ("pending", "in_progress", "completed"):
|
|
35
|
+
raise ValueError(f"Item {item_id}: invalid status '{status}'")
|
|
36
|
+
if status == "in_progress":
|
|
37
|
+
in_progress_count += 1
|
|
38
|
+
validated.append({"id": item_id, "text": text, "status": status})
|
|
39
|
+
|
|
40
|
+
if in_progress_count > 1:
|
|
41
|
+
raise ValueError("Only one task can be in_progress at a time")
|
|
42
|
+
|
|
43
|
+
self.items = validated
|
|
44
|
+
result = self.render()
|
|
45
|
+
done = sum(1 for t in self.items if t["status"] == "completed")
|
|
46
|
+
|
|
47
|
+
# Publish todo state for any UI subscriber.
|
|
48
|
+
session_id = get_session_id()
|
|
49
|
+
get_event_bus().publish(
|
|
50
|
+
AgentEvent(
|
|
51
|
+
type=AgentEventType.TODO_UPDATED,
|
|
52
|
+
data=TodoUpdatedPayload(
|
|
53
|
+
items=[dict(x) for x in self.items],
|
|
54
|
+
counts={"total": len(self.items), "completed": done},
|
|
55
|
+
rendered=result,
|
|
56
|
+
text=result,
|
|
57
|
+
),
|
|
58
|
+
session_id=session_id,
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
return result
|
|
62
|
+
|
|
63
|
+
def render(self) -> str:
|
|
64
|
+
if not self.items:
|
|
65
|
+
return "No todos."
|
|
66
|
+
lines: list[str] = []
|
|
67
|
+
for item in self.items:
|
|
68
|
+
marker = {"pending": "[ ]", "in_progress": "[>]", "completed": "[x]"}[item["status"]]
|
|
69
|
+
lines.append(f"{marker} #{item['id']}: {item['text']}")
|
|
70
|
+
done = sum(1 for t in self.items if t["status"] == "completed")
|
|
71
|
+
lines.append(f"\n({done}/{len(self.items)} completed)")
|
|
72
|
+
return "\n".join(lines)
|
|
73
|
+
|
|
74
|
+
def snapshot_for_prompt(self) -> str:
|
|
75
|
+
if not self.items:
|
|
76
|
+
return ""
|
|
77
|
+
return f"\n<current_todos>\n{self.render()}\n</current_todos>"
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def has_in_progress(self) -> bool:
|
|
81
|
+
return any(item["status"] == "in_progress" for item in self.items)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class ContextManager:
|
|
86
|
+
"""Per-session agent context: usage tracking + microcompact + todo state.
|
|
87
|
+
|
|
88
|
+
LLM-summary compaction lives in :mod:`power_loop.runtime.compact`
|
|
89
|
+
(configured via ``AgentLoopConfig.compactor``). This class only owns
|
|
90
|
+
the orthogonal "spill large tool outputs to disk" path and the
|
|
91
|
+
telemetry-friendly :meth:`update_usage` parser.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
role: str = "main"
|
|
95
|
+
recent_files: list[str] = field(default_factory=list)
|
|
96
|
+
_file_counter: int = 0
|
|
97
|
+
|
|
98
|
+
token_usage: dict[str, Any] = field(default_factory=dict)
|
|
99
|
+
#: 可选计数器,供测试/扩展使用;**不会**被 ``update_usage`` 自动递增
|
|
100
|
+
api_calls: int = 0
|
|
101
|
+
|
|
102
|
+
subagent_records: list[dict[str, Any]] = field(default_factory=list)
|
|
103
|
+
|
|
104
|
+
todo: TodoManager = field(default_factory=TodoManager)
|
|
105
|
+
|
|
106
|
+
# Microcompact (large tool-output spill-to-disk) config — orthogonal to
|
|
107
|
+
# the LLM-summary Compactor in runtime/compact.py.
|
|
108
|
+
micro_hot_tail: int = field(default_factory=lambda: int(os.getenv("CONTEXT_MICRO_HOT_TAIL", "10")))
|
|
109
|
+
micro_size_limit: int = field(default_factory=lambda: int(os.getenv("CONTEXT_MICRO_SIZE_LIMIT", "1000")))
|
|
110
|
+
|
|
111
|
+
cache_dir: Path = field(default_factory=lambda: (AGENT_DIR / ".cache"))
|
|
112
|
+
|
|
113
|
+
def __post_init__(self) -> None:
|
|
114
|
+
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
115
|
+
# Ensure skills loader reads from correct runtime env.
|
|
116
|
+
_ = get_default_loader()
|
|
117
|
+
|
|
118
|
+
def track_file(self, path: str) -> None:
|
|
119
|
+
if not path:
|
|
120
|
+
return
|
|
121
|
+
if path in self.recent_files:
|
|
122
|
+
self.recent_files.remove(path)
|
|
123
|
+
self.recent_files.append(path)
|
|
124
|
+
self.recent_files = self.recent_files[-5:]
|
|
125
|
+
|
|
126
|
+
def update_usage(self, response: LLMResponse) -> dict[str, int]:
|
|
127
|
+
usage = None
|
|
128
|
+
if getattr(response, "token_usage", None) is not None:
|
|
129
|
+
tu = response.token_usage
|
|
130
|
+
usage = tu.as_dict() if hasattr(tu, "as_dict") else None
|
|
131
|
+
|
|
132
|
+
def _pick(dct: dict, keys: list[str]) -> int:
|
|
133
|
+
for key in keys:
|
|
134
|
+
val = dct.get(key)
|
|
135
|
+
if isinstance(val, (int, float)) and val is not None:
|
|
136
|
+
return int(val)
|
|
137
|
+
return 0
|
|
138
|
+
|
|
139
|
+
input_tokens = 0
|
|
140
|
+
output_tokens = 0
|
|
141
|
+
cache_read = 0
|
|
142
|
+
reasoning = 0
|
|
143
|
+
total_tokens = 0
|
|
144
|
+
|
|
145
|
+
if isinstance(usage, dict):
|
|
146
|
+
input_tokens = _pick(usage, ["prompt_tokens", "input_tokens"])
|
|
147
|
+
output_tokens = _pick(usage, ["completion_tokens", "output_tokens"])
|
|
148
|
+
cache_read = _pick(
|
|
149
|
+
usage,
|
|
150
|
+
[
|
|
151
|
+
"cache_read_input_tokens",
|
|
152
|
+
"cache_read_tokens",
|
|
153
|
+
"cached_tokens",
|
|
154
|
+
"cache_hit_tokens",
|
|
155
|
+
"prompt_cached_tokens",
|
|
156
|
+
],
|
|
157
|
+
)
|
|
158
|
+
reasoning = _pick(usage, ["completion_reasoning_tokens", "reasoning_tokens"])
|
|
159
|
+
total_tokens = _pick(usage, ["total_tokens"])
|
|
160
|
+
|
|
161
|
+
usage_out: dict[str, int] = {
|
|
162
|
+
"prompt_tokens": input_tokens,
|
|
163
|
+
"completion_tokens": output_tokens,
|
|
164
|
+
"cache_read_tokens": cache_read,
|
|
165
|
+
"reasoning_tokens": reasoning,
|
|
166
|
+
"total_tokens": total_tokens,
|
|
167
|
+
# 与常见 OpenAI usage 字段对齐的别名
|
|
168
|
+
"input": input_tokens,
|
|
169
|
+
"output": output_tokens,
|
|
170
|
+
"cache_read": cache_read,
|
|
171
|
+
"reasoning": reasoning,
|
|
172
|
+
}
|
|
173
|
+
self.token_usage = usage_out
|
|
174
|
+
return usage_out
|
|
175
|
+
|
|
176
|
+
def microcompact(self, messages: list[dict[str, Any]]) -> None:
|
|
177
|
+
# Keep hot tail tool outputs; summarize/cached replace for old tool outputs.
|
|
178
|
+
tool_output_indices: list[int] = []
|
|
179
|
+
for i, msg in enumerate(messages):
|
|
180
|
+
if msg.get("role") == "tool":
|
|
181
|
+
content = msg.get("content", "")
|
|
182
|
+
if isinstance(content, str) and len(content) > self.micro_size_limit:
|
|
183
|
+
tool_output_indices.append(i)
|
|
184
|
+
|
|
185
|
+
if len(tool_output_indices) <= self.micro_hot_tail:
|
|
186
|
+
return
|
|
187
|
+
|
|
188
|
+
cold = tool_output_indices[:-self.micro_hot_tail]
|
|
189
|
+
for i in cold:
|
|
190
|
+
msg = messages[i]
|
|
191
|
+
content = msg.get("content", "")
|
|
192
|
+
if not isinstance(content, str):
|
|
193
|
+
continue
|
|
194
|
+
if content.startswith("[tool output saved to"):
|
|
195
|
+
continue
|
|
196
|
+
self._file_counter += 1
|
|
197
|
+
cache_path = self.cache_dir / f"tool_{self._file_counter:05d}.md"
|
|
198
|
+
tool_name = str(msg.get("name") or "tool")
|
|
199
|
+
tool_id = str(msg.get("tool_call_id") or "")
|
|
200
|
+
md = (
|
|
201
|
+
f"# Tool Call: {tool_name}\n\n"
|
|
202
|
+
f"**ID**: `{tool_id}`\n\n"
|
|
203
|
+
f"**Output** ({len(content)} chars):\n\n"
|
|
204
|
+
f"{content}\n"
|
|
205
|
+
)
|
|
206
|
+
cache_path.write_text(md, encoding="utf-8")
|
|
207
|
+
replaced = f"[tool output saved to {cache_path.relative_to(AGENT_DIR)}, {tool_name}, {len(content)} chars]"
|
|
208
|
+
msg["content"] = replaced
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Token budgeting utilities — heuristic, vendor-neutral.
|
|
2
|
+
|
|
3
|
+
These are deliberately approximate: we want a single number that's cheap to
|
|
4
|
+
compute, monotonic with content size, and works without a tokenizer
|
|
5
|
+
dependency. Used by the compactor to decide *when* to trigger; not used for
|
|
6
|
+
billing.
|
|
7
|
+
|
|
8
|
+
Rule of thumb: ~4 chars per token for English-heavy LLM transcripts. Adjust
|
|
9
|
+
via :data:`CHARS_PER_TOKEN` if needed.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
CHARS_PER_TOKEN = 4
|
|
18
|
+
"""Approximate chars-per-token used by :func:`estimate_tokens`."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def estimate_text_tokens(text: str | None) -> int:
|
|
22
|
+
if not text:
|
|
23
|
+
return 0
|
|
24
|
+
return max(1, len(text) // CHARS_PER_TOKEN)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def estimate_message_tokens(message: dict[str, Any]) -> int:
|
|
28
|
+
"""Heuristic token count for a single message dict.
|
|
29
|
+
|
|
30
|
+
Counts content (string or JSON-serialized non-string), tool_calls
|
|
31
|
+
arguments, name fields, and a small per-message overhead for the role
|
|
32
|
+
framing.
|
|
33
|
+
"""
|
|
34
|
+
overhead = 4 # role tag + delimiters
|
|
35
|
+
content = message.get("content")
|
|
36
|
+
if isinstance(content, str):
|
|
37
|
+
body = content
|
|
38
|
+
elif content is None:
|
|
39
|
+
body = ""
|
|
40
|
+
else:
|
|
41
|
+
body = json.dumps(content, ensure_ascii=False)
|
|
42
|
+
tool_calls = message.get("tool_calls") or []
|
|
43
|
+
if tool_calls:
|
|
44
|
+
body += json.dumps(tool_calls, ensure_ascii=False)
|
|
45
|
+
name = message.get("name") or ""
|
|
46
|
+
return overhead + estimate_text_tokens(body) + estimate_text_tokens(name)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def estimate_tokens(messages: list[dict[str, Any]]) -> int:
|
|
50
|
+
return sum(estimate_message_tokens(m) for m in messages)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def trim_history(
|
|
54
|
+
messages: list[dict[str, Any]],
|
|
55
|
+
max_tokens: int,
|
|
56
|
+
*,
|
|
57
|
+
keep_system: bool = True,
|
|
58
|
+
keep_last_n: int = 2,
|
|
59
|
+
) -> list[dict[str, Any]]:
|
|
60
|
+
"""Drop messages from the middle until the token budget fits.
|
|
61
|
+
|
|
62
|
+
Used by callers that want to cap context before calling the LLM
|
|
63
|
+
directly (without the full compactor pipeline). This is a **pure
|
|
64
|
+
trim** — no summarisation, no LLM calls. For LLM-backed summary
|
|
65
|
+
compaction, use :class:`power_loop.runtime.compact.DefaultCompactor`.
|
|
66
|
+
|
|
67
|
+
Preserves:
|
|
68
|
+
* All ``role=system`` messages at the front (when ``keep_system=True``).
|
|
69
|
+
* The last ``keep_last_n`` exchanges (user-bounded). An exchange is
|
|
70
|
+
a ``user`` message followed by everything up to (but not including)
|
|
71
|
+
the next ``user`` — so ``assistant(tool_calls) ↔ tool`` pairs stay
|
|
72
|
+
together.
|
|
73
|
+
* The ``assistant(tool_calls)`` ↔ ``tool`` atomic pair near the cut
|
|
74
|
+
boundary: if the first removed message is a ``tool``, we walk back
|
|
75
|
+
to include the preceding ``assistant(tool_calls)`` so the protocol
|
|
76
|
+
isn't broken.
|
|
77
|
+
|
|
78
|
+
Returns a new list (does not mutate the input). If the budget
|
|
79
|
+
already fits, the original list is returned unchanged.
|
|
80
|
+
"""
|
|
81
|
+
if not messages or max_tokens <= 0:
|
|
82
|
+
return []
|
|
83
|
+
|
|
84
|
+
if estimate_tokens(messages) <= max_tokens:
|
|
85
|
+
return list(messages)
|
|
86
|
+
|
|
87
|
+
# Partition: leading system block, body, tail.
|
|
88
|
+
n = len(messages)
|
|
89
|
+
sys_end = 0
|
|
90
|
+
if keep_system:
|
|
91
|
+
while sys_end < n and messages[sys_end].get("role") == "system":
|
|
92
|
+
sys_end += 1
|
|
93
|
+
|
|
94
|
+
# Find tail exchanges (keep_last_n) by walking backwards from the end.
|
|
95
|
+
exchanges = 0
|
|
96
|
+
tail_start = n
|
|
97
|
+
for i in range(n - 1, sys_end - 1, -1):
|
|
98
|
+
if messages[i].get("role") == "user":
|
|
99
|
+
exchanges += 1
|
|
100
|
+
if exchanges >= keep_last_n:
|
|
101
|
+
tail_start = i
|
|
102
|
+
break
|
|
103
|
+
else:
|
|
104
|
+
tail_start = sys_end # not enough exchanges — keep everything after system
|
|
105
|
+
|
|
106
|
+
# Walk the cut boundary forward to preserve tool_calls ↔ tool atomicity.
|
|
107
|
+
# If the first message we'd drop is a ``tool``, extend the tail back
|
|
108
|
+
# until we include the preceding ``assistant(tool_calls)``.
|
|
109
|
+
while tail_start > sys_end and messages[tail_start - 1].get("role") == "tool" \
|
|
110
|
+
and messages[tail_start - 1].get("tool_call_id"):
|
|
111
|
+
# Walk back to find the matching assistant(tool_calls).
|
|
112
|
+
tid = messages[tail_start - 1].get("tool_call_id")
|
|
113
|
+
extended = tail_start - 1
|
|
114
|
+
while extended > sys_end:
|
|
115
|
+
extended -= 1
|
|
116
|
+
role = messages[extended].get("role")
|
|
117
|
+
if role == "assistant" and messages[extended].get("tool_calls"):
|
|
118
|
+
# Check if any of its tool_calls match this tool_call_id.
|
|
119
|
+
tcs = messages[extended].get("tool_calls") or []
|
|
120
|
+
if any(tc.get("id") == tid for tc in tcs):
|
|
121
|
+
tail_start = extended
|
|
122
|
+
break
|
|
123
|
+
elif role == "user":
|
|
124
|
+
# We hit a user boundary without finding the pair — stop.
|
|
125
|
+
break
|
|
126
|
+
else:
|
|
127
|
+
break # safety: cannot find pair, stop extending
|
|
128
|
+
|
|
129
|
+
# Build result: system + trimmed body + tail.
|
|
130
|
+
budget_tail = estimate_tokens(messages[tail_start:])
|
|
131
|
+
remaining = max_tokens - budget_tail
|
|
132
|
+
system_tokens = estimate_tokens(messages[:sys_end])
|
|
133
|
+
body_budget = remaining - system_tokens
|
|
134
|
+
|
|
135
|
+
if body_budget < 0:
|
|
136
|
+
# Can't even fit system + tail. Degrade: drop system, keep only tail.
|
|
137
|
+
if estimate_tokens(messages[tail_start:]) > max_tokens:
|
|
138
|
+
# Tail alone exceeds budget — last resort: trim from tail end too.
|
|
139
|
+
result: list[dict[str, Any]] = []
|
|
140
|
+
spent = 0
|
|
141
|
+
orphan_tool_ids: set[str] = set() # tool msgs in result whose assistant is not yet in
|
|
142
|
+
for m in reversed(messages[tail_start:]):
|
|
143
|
+
cost = estimate_message_tokens(m)
|
|
144
|
+
tid = m.get("tool_call_id") or ""
|
|
145
|
+
if spent + cost > max_tokens:
|
|
146
|
+
# Atomicity: if this is an assistant(tool_calls) whose
|
|
147
|
+
# tool msg is already in the result, include it anyway.
|
|
148
|
+
if m.get("role") == "assistant" and m.get("tool_calls"):
|
|
149
|
+
tcs = m.get("tool_calls") or []
|
|
150
|
+
if any(tc.get("id") in orphan_tool_ids for tc in tcs):
|
|
151
|
+
result.insert(0, m)
|
|
152
|
+
spent += cost
|
|
153
|
+
# Also: tool message whose assistant is not yet in result
|
|
154
|
+
# — skip it (will be pulled in by the assistant if it fits).
|
|
155
|
+
elif m.get("role") == "tool" and tid:
|
|
156
|
+
pass # leave orphan_tool_ids entry; don't advance
|
|
157
|
+
break
|
|
158
|
+
result.insert(0, m)
|
|
159
|
+
spent += cost
|
|
160
|
+
if m.get("role") == "assistant" and m.get("tool_calls"):
|
|
161
|
+
for tc in (m.get("tool_calls") or []):
|
|
162
|
+
orphan_tool_ids.discard(tc.get("id") or "")
|
|
163
|
+
elif m.get("role") == "tool" and tid:
|
|
164
|
+
orphan_tool_ids.add(tid)
|
|
165
|
+
return result
|
|
166
|
+
return list(messages[tail_start:])
|
|
167
|
+
|
|
168
|
+
# Take body messages from index sys_end to tail_start, keep as many
|
|
169
|
+
# from the front as fit.
|
|
170
|
+
body: list[dict[str, Any]] = []
|
|
171
|
+
spent = system_tokens
|
|
172
|
+
for i in range(sys_end, tail_start):
|
|
173
|
+
cost = estimate_message_tokens(messages[i])
|
|
174
|
+
if spent + cost > max_tokens - budget_tail:
|
|
175
|
+
break
|
|
176
|
+
body.append(messages[i])
|
|
177
|
+
spent += cost
|
|
178
|
+
|
|
179
|
+
return list(messages[:sys_end]) + body + list(messages[tail_start:])
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Unified cancellation primitive for power-loop.
|
|
2
|
+
|
|
3
|
+
Why
|
|
4
|
+
---
|
|
5
|
+
Pipelines and tool handlers come from many places — sync threads, asyncio
|
|
6
|
+
tasks, hook callbacks. Different callers naturally hold different "cancel
|
|
7
|
+
signals":
|
|
8
|
+
|
|
9
|
+
* a long-running CLI may use ``threading.Event``;
|
|
10
|
+
* an asyncio server may use ``asyncio.Event``;
|
|
11
|
+
* a UI framework may expose only ``is_cancelled()`` as a callable.
|
|
12
|
+
|
|
13
|
+
``CancellationToken`` is the **one shape** the pipeline checks. Callers pass
|
|
14
|
+
whatever they have; ``from_any`` lifts it into a token. There is also a
|
|
15
|
+
plain "owned" token (``CancellationToken()``) that callers can ``cancel()``
|
|
16
|
+
themselves — used by hook ``HookDirective.CANCEL`` (M1.5) and by
|
|
17
|
+
``StatefulAgentLoop.cancel(sid)``-style helpers.
|
|
18
|
+
|
|
19
|
+
The token is **read-only from the pipeline's side**: it never *creates*
|
|
20
|
+
cancellation, only observes it. The only mutating method, ``cancel()``,
|
|
21
|
+
is for callers that explicitly created an owned token.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import asyncio
|
|
27
|
+
import threading
|
|
28
|
+
from collections.abc import Callable
|
|
29
|
+
from typing import Union
|
|
30
|
+
|
|
31
|
+
from power_loop.contracts.errors import CancellationRequested
|
|
32
|
+
|
|
33
|
+
# What callers may hand us. ``None`` means "no cancellation".
|
|
34
|
+
CancellationLike = Union["CancellationToken", asyncio.Event, threading.Event, Callable[[], bool], None]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class CancellationToken:
|
|
38
|
+
"""Observable cancellation flag.
|
|
39
|
+
|
|
40
|
+
Construct it three ways:
|
|
41
|
+
|
|
42
|
+
* ``CancellationToken()`` — owned; flip with ``cancel()``.
|
|
43
|
+
* ``CancellationToken.from_any(obj)`` — wrap an existing event / callable.
|
|
44
|
+
* ``CancellationToken.never()`` — sentinel that is never cancelled
|
|
45
|
+
(sugar for "the caller passed None").
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
__slots__ = ("_check", "_reason", "_owned_event")
|
|
49
|
+
|
|
50
|
+
def __init__(self, *, _check: Callable[[], bool] | None = None) -> None:
|
|
51
|
+
# Owned mode: a private threading.Event so ``cancel()`` works and
|
|
52
|
+
# waiters relying on Event semantics keep working.
|
|
53
|
+
self._owned_event = threading.Event()
|
|
54
|
+
if _check is None:
|
|
55
|
+
self._check = self._owned_event.is_set
|
|
56
|
+
else:
|
|
57
|
+
self._check = _check
|
|
58
|
+
self._reason: str = "cancelled"
|
|
59
|
+
|
|
60
|
+
# ── Factories ───────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
@classmethod
|
|
63
|
+
def from_any(cls, source: CancellationLike) -> CancellationToken:
|
|
64
|
+
"""Lift any cancel-like object into a token. ``None`` → ``never()``."""
|
|
65
|
+
if source is None:
|
|
66
|
+
return cls.never()
|
|
67
|
+
if isinstance(source, CancellationToken):
|
|
68
|
+
return source
|
|
69
|
+
if isinstance(source, threading.Event):
|
|
70
|
+
tok = cls.__new__(cls)
|
|
71
|
+
tok._owned_event = source
|
|
72
|
+
tok._check = source.is_set
|
|
73
|
+
tok._reason = "cancelled"
|
|
74
|
+
return tok
|
|
75
|
+
if isinstance(source, asyncio.Event):
|
|
76
|
+
tok = cls.__new__(cls)
|
|
77
|
+
tok._owned_event = threading.Event() # unused, kept for shape
|
|
78
|
+
tok._check = source.is_set
|
|
79
|
+
tok._reason = "cancelled"
|
|
80
|
+
return tok
|
|
81
|
+
if callable(source):
|
|
82
|
+
tok = cls.__new__(cls)
|
|
83
|
+
tok._owned_event = threading.Event()
|
|
84
|
+
tok._check = source
|
|
85
|
+
tok._reason = "cancelled"
|
|
86
|
+
return tok
|
|
87
|
+
raise TypeError(
|
|
88
|
+
f"CancellationToken.from_any: unsupported source type {type(source).__name__}"
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
@classmethod
|
|
92
|
+
def never(cls) -> CancellationToken:
|
|
93
|
+
"""Sentinel token that is never cancelled."""
|
|
94
|
+
tok = cls.__new__(cls)
|
|
95
|
+
tok._owned_event = threading.Event()
|
|
96
|
+
tok._check = lambda: False
|
|
97
|
+
tok._reason = "cancelled"
|
|
98
|
+
return tok
|
|
99
|
+
|
|
100
|
+
# ── Observation ─────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
def is_cancelled(self) -> bool:
|
|
103
|
+
try:
|
|
104
|
+
return bool(self._check())
|
|
105
|
+
except Exception:
|
|
106
|
+
# A user-supplied callable that raises is treated as "not cancelled"
|
|
107
|
+
# rather than corrupting loop control flow. Errors in cancellation
|
|
108
|
+
# checks should never abort the loop.
|
|
109
|
+
return False
|
|
110
|
+
|
|
111
|
+
def raise_if_cancelled(self) -> None:
|
|
112
|
+
if self.is_cancelled():
|
|
113
|
+
raise CancellationRequested(self._reason)
|
|
114
|
+
|
|
115
|
+
# ── Mutation (owned tokens only) ────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
def cancel(self, reason: str = "cancelled") -> None:
|
|
118
|
+
"""Flip an owned token. No-op (and safe) on wrapped tokens whose
|
|
119
|
+
underlying source isn't an Event we own — the underlying source is
|
|
120
|
+
the canonical signal there."""
|
|
121
|
+
self._reason = reason
|
|
122
|
+
# We only know how to flip the owned threading.Event. Wrapped
|
|
123
|
+
# asyncio.Event / callable sources must be flipped by their owner.
|
|
124
|
+
self._owned_event.set()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
__all__ = ["CancellationToken", "CancellationLike"]
|