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.
Files changed (53) hide show
  1. llm_client/__init__.py +0 -0
  2. llm_client/capabilities.py +162 -0
  3. llm_client/interface.py +470 -0
  4. llm_client/llm_factory.py +981 -0
  5. llm_client/llm_tooling.py +645 -0
  6. llm_client/llm_utils.py +205 -0
  7. llm_client/multimodal.py +237 -0
  8. llm_client/qwen_image.py +576 -0
  9. llm_client/web_search.py +149 -0
  10. power_loop/__init__.py +326 -0
  11. power_loop/agent/__init__.py +6 -0
  12. power_loop/agent/sink.py +247 -0
  13. power_loop/agent/stateful_loop.py +363 -0
  14. power_loop/agent/system_prompt.py +396 -0
  15. power_loop/agent/types.py +41 -0
  16. power_loop/contracts/__init__.py +132 -0
  17. power_loop/contracts/errors.py +140 -0
  18. power_loop/contracts/event_payloads.py +278 -0
  19. power_loop/contracts/events.py +86 -0
  20. power_loop/contracts/handlers.py +45 -0
  21. power_loop/contracts/hook_contexts.py +265 -0
  22. power_loop/contracts/hooks.py +64 -0
  23. power_loop/contracts/messages.py +90 -0
  24. power_loop/contracts/protocols.py +48 -0
  25. power_loop/contracts/tools.py +56 -0
  26. power_loop/core/agent_context.py +94 -0
  27. power_loop/core/events.py +124 -0
  28. power_loop/core/hooks.py +122 -0
  29. power_loop/core/phase.py +217 -0
  30. power_loop/core/pipeline.py +880 -0
  31. power_loop/core/runner.py +60 -0
  32. power_loop/core/state.py +208 -0
  33. power_loop/runtime/budget.py +179 -0
  34. power_loop/runtime/cancellation.py +127 -0
  35. power_loop/runtime/compact.py +300 -0
  36. power_loop/runtime/env.py +103 -0
  37. power_loop/runtime/memory.py +107 -0
  38. power_loop/runtime/provider.py +176 -0
  39. power_loop/runtime/retry.py +182 -0
  40. power_loop/runtime/session_store.py +636 -0
  41. power_loop/runtime/skills.py +201 -0
  42. power_loop/runtime/spec.py +233 -0
  43. power_loop/runtime/structured.py +225 -0
  44. power_loop/tools/__init__.py +51 -0
  45. power_loop/tools/default_manifest.py +244 -0
  46. power_loop/tools/default_tools.py +766 -0
  47. power_loop/tools/registry.py +162 -0
  48. power_loop/tools/spawn_agent.py +173 -0
  49. power_loop-0.2.0.dist-info/METADATA +632 -0
  50. power_loop-0.2.0.dist-info/RECORD +53 -0
  51. power_loop-0.2.0.dist-info/WHEEL +5 -0
  52. power_loop-0.2.0.dist-info/licenses/LICENSE +21 -0
  53. 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
+
@@ -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"]