power-loop 6.2.0__tar.gz → 6.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-6.2.0 → power_loop-6.4.0}/PKG-INFO +1 -1
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/__init__.py +1 -1
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/core/pipeline.py +12 -6
- power_loop-6.4.0/power_loop/runtime/image_recall.py +147 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop.egg-info/PKG-INFO +1 -1
- power_loop-6.2.0/power_loop/runtime/image_recall.py +0 -87
- {power_loop-6.2.0 → power_loop-6.4.0}/LICENSE +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/README.md +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/_vendor/__init__.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/_vendor/llm_client/__init__.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/_vendor/llm_client/anthropic_factory.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/_vendor/llm_client/capabilities.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/_vendor/llm_client/interface.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/_vendor/llm_client/llm_factory.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/_vendor/llm_client/llm_tooling.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/_vendor/llm_client/llm_utils.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/_vendor/llm_client/multimodal.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/agent/__init__.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/agent/follow_up.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/agent/sink.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/agent/stateful_loop.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/agent/system_prompt.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/agent/types.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/contracts/__init__.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/contracts/errors.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/contracts/event_payloads.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/contracts/events.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/contracts/handlers.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/contracts/hook_contexts.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/contracts/hooks.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/contracts/messages.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/contracts/protocols.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/contracts/tools.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/contrib/__init__.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/contrib/_redact.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/contrib/jsonl_sink.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/contrib/logging_sink.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/contrib/mcp.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/contrib/metrics_sink.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/contrib/otel_sink.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/core/agent_context.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/core/events.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/core/hooks.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/core/phase.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/core/runner.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/core/state.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/py.typed +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/runtime/blackboard.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/runtime/budget.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/runtime/cancellation.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/runtime/compact.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/runtime/env.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/runtime/exec_backend.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/runtime/fold.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/runtime/fold_adapter.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/runtime/history_projector.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/runtime/history_sanitize.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/runtime/human_input.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/runtime/memory.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/runtime/notes.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/runtime/provider.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/runtime/representation.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/runtime/retry.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/runtime/runtime_state.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/runtime/session_store.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/runtime/skills.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/runtime/spec.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/runtime/store/__init__.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/runtime/store/backends/__init__.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/runtime/store/backends/mysql.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/runtime/store/backends/postgres.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/runtime/store/backends/sqlite.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/runtime/store/capabilities.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/runtime/store/db.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/runtime/store/dialect.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/runtime/store/factory.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/runtime/store/schema.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/runtime/store/store.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/runtime/store/types.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/runtime/structured.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/runtime/stub_provider.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/runtime/timers.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/tools/__init__.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/tools/blackboard.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/tools/command_policy.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/tools/default_manifest.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/tools/default_tools.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/tools/registry.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/tools/spawn_agent.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/workflow/__init__.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/workflow/api.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/workflow/engine.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/workflow/introspect.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/workflow/journal.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/workflow/result.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/workflow/resume.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/workflow/runner.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/workflow/spec.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/workflow/subprocess_executor.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/workflow/tool.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/workflow/worker.py +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop.egg-info/SOURCES.txt +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop.egg-info/dependency_links.txt +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop.egg-info/requires.txt +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/power_loop.egg-info/top_level.txt +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/pyproject.toml +0 -0
- {power_loop-6.2.0 → power_loop-6.4.0}/setup.cfg +0 -0
|
@@ -15,7 +15,7 @@ Stability tiers
|
|
|
15
15
|
无版本承诺,可随时变更或删除。
|
|
16
16
|
"""
|
|
17
17
|
|
|
18
|
-
__version__ = "6.
|
|
18
|
+
__version__ = "6.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).
|
|
@@ -1044,12 +1044,18 @@ class AgentPipeline:
|
|
|
1044
1044
|
await self._append_message(_pm, round_index=round_idx)
|
|
1045
1045
|
llm_before.messages.append(dict(_pm))
|
|
1046
1046
|
|
|
1047
|
-
#
|
|
1048
|
-
#
|
|
1049
|
-
#
|
|
1050
|
-
#
|
|
1051
|
-
#
|
|
1052
|
-
|
|
1047
|
+
# Image recall: a tool (see_image / recall_send / an image generator) can put
|
|
1048
|
+
# pictures in front of the model. DURABLE ones become real `user` rows — the image
|
|
1049
|
+
# stays in view for the rest of the send (cheap: it sits in the provider's cached
|
|
1050
|
+
# prefix) and is distilled to `[image: … · file_uuid=…]` across sends, so nothing
|
|
1051
|
+
# accumulates without bound. EPHEMERAL ones go into this request only.
|
|
1052
|
+
# Appended AFTER the hook's own persist_messages so a tool's picture lands below
|
|
1053
|
+
# the tool result that announced it — which is exactly where the model expects it.
|
|
1054
|
+
_durable_imgs, _ephemeral_imgs = drain_queued_images(self.session_id)
|
|
1055
|
+
for _img in _durable_imgs:
|
|
1056
|
+
await self._append_message(_img, round_index=round_idx)
|
|
1057
|
+
llm_before.messages.append(dict(_img))
|
|
1058
|
+
for _img in _ephemeral_imgs:
|
|
1053
1059
|
llm_before.messages.append(_img)
|
|
1054
1060
|
|
|
1055
1061
|
if llm_before.directive == HookDirective.SHORT_CIRCUIT:
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Put an image back in front of the model for ONE round.
|
|
2
|
+
|
|
3
|
+
Why this exists: a projection deliberately distils a past image down to a text reference
|
|
4
|
+
(``[image: shot.png]``) — keeping the picture would re-send it on every later send and the
|
|
5
|
+
token bill grows without bound. But an agent that later wants to LOOK at that image again has
|
|
6
|
+
nothing to look at.
|
|
7
|
+
|
|
8
|
+
Why it is not just a tool return value: the OpenAI-compatible protocol does not allow images
|
|
9
|
+
in a ``tool`` message — ``ChatCompletionToolMessageParam.content`` is typed as
|
|
10
|
+
``str | Iterable[ChatCompletionContentPartTextParam]``, text parts only. (Anthropic's
|
|
11
|
+
``tool_result`` does allow image blocks, but building on that would make history shape depend
|
|
12
|
+
on which vendor the definition happens to point at, and definitions switch models at runtime.)
|
|
13
|
+
So the image travels as its own **user** message instead, and the tool returns only a line
|
|
14
|
+
saying it is there.
|
|
15
|
+
|
|
16
|
+
DURABLE by default, and that default was reversed on purpose. The first cut was ephemeral —
|
|
17
|
+
request-only, gone next round — out of a fear that "recall three times and you carry three
|
|
18
|
+
images forever". Measurement killed that fear: the provider's prefix cache serves a stable
|
|
19
|
+
history at ~99% hit rate, so an image sitting in the prefix costs about a tenth of its face
|
|
20
|
+
value every later round. Meanwhile the SEMANTICS clearly favour durable: an agent that looked
|
|
21
|
+
at a UI screenshot and then spends fifteen rounds coding against it should still have the
|
|
22
|
+
picture in front of it, not have to ask for it again. Across sends the projection distils the
|
|
23
|
+
row down to `[image: shot.png · file_uuid=…]`, so nothing accumulates without bound.
|
|
24
|
+
|
|
25
|
+
Pass ``durable=False`` for the look-once case (request-only, never stored).
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import threading
|
|
31
|
+
from collections.abc import Sequence
|
|
32
|
+
from typing import Any
|
|
33
|
+
|
|
34
|
+
LoopMessage = dict[str, Any]
|
|
35
|
+
|
|
36
|
+
# session_id -> messages queued for the NEXT round of that session. Module-level rather than a
|
|
37
|
+
# contextvar because a tool handler may run in a worker thread (``asyncio.to_thread``), which
|
|
38
|
+
# does not inherit the caller's context.
|
|
39
|
+
_pending: dict[str, list[LoopMessage]] = {}
|
|
40
|
+
_lock = threading.Lock()
|
|
41
|
+
|
|
42
|
+
#: A queue is drained every round, so anything left is a tool that queued without a round
|
|
43
|
+
#: following. Cap it so a pathological loop cannot grow the dict without bound.
|
|
44
|
+
MAX_PENDING_PER_SESSION = 8
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def queue_image_for_next_round(
|
|
48
|
+
session_id: str | None, *, path: str, note: str = "", ref: str = "",
|
|
49
|
+
durable: bool = True,
|
|
50
|
+
) -> bool:
|
|
51
|
+
"""Queue one image (by local path) to be shown to the model from the next round on.
|
|
52
|
+
|
|
53
|
+
``durable=True`` (default) stores it as a real ``user`` row, so it stays in front of the
|
|
54
|
+
model for the rest of the send and is distilled to a text reference across sends.
|
|
55
|
+
``durable=False`` puts it in the request only — visible for exactly one round.
|
|
56
|
+
|
|
57
|
+
Returns False when there is no session to queue against, or the queue is full — the caller
|
|
58
|
+
should then say so in its own return value rather than pretend the image was delivered.
|
|
59
|
+
"""
|
|
60
|
+
if not session_id or not path:
|
|
61
|
+
return False
|
|
62
|
+
blocks: list[dict[str, Any]] = []
|
|
63
|
+
if note:
|
|
64
|
+
blocks.append({"type": "text", "text": note})
|
|
65
|
+
# An `attachment` block (path, not bytes) — the renderer reads the file, applies the
|
|
66
|
+
# model's declared size limit and encodes it at request time.
|
|
67
|
+
blocks.append({
|
|
68
|
+
"type": "attachment",
|
|
69
|
+
# Carry the host's recall coordinate through: if the definition has meanwhile switched to
|
|
70
|
+
# a model that cannot see images, this is what the placeholder text will point at.
|
|
71
|
+
"attachment": _attachment_ref(path, ref=ref),
|
|
72
|
+
})
|
|
73
|
+
with _lock:
|
|
74
|
+
queue = _pending.setdefault(session_id, [])
|
|
75
|
+
if len(queue) >= MAX_PENDING_PER_SESSION:
|
|
76
|
+
return False
|
|
77
|
+
queue.append({"role": "user", "content": blocks, "__durable__": durable})
|
|
78
|
+
return True
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def queue_images_for_next_round(
|
|
82
|
+
session_id: str | None,
|
|
83
|
+
images: Sequence[tuple[str, str]],
|
|
84
|
+
*,
|
|
85
|
+
note: str = "",
|
|
86
|
+
durable: bool = True,
|
|
87
|
+
) -> int:
|
|
88
|
+
"""Queue a BATCH as ONE user message: a single note + one attachment block per image.
|
|
89
|
+
|
|
90
|
+
Queuing them one by one produces N separate user turns, each repeating the note — three
|
|
91
|
+
screenshots asked about with one question became three copies of that question in the
|
|
92
|
+
transcript. A batch is one turn, which is also what the provider APIs expect.
|
|
93
|
+
|
|
94
|
+
``images`` is ``[(path, ref)]``. Returns how many were accepted (0 = nothing queued, so the
|
|
95
|
+
caller must say so rather than claim the pictures were delivered).
|
|
96
|
+
"""
|
|
97
|
+
if not session_id:
|
|
98
|
+
return 0
|
|
99
|
+
blocks: list[dict[str, Any]] = []
|
|
100
|
+
if note:
|
|
101
|
+
blocks.append({"type": "text", "text": note})
|
|
102
|
+
accepted = 0
|
|
103
|
+
for path, ref in images:
|
|
104
|
+
if not path:
|
|
105
|
+
continue
|
|
106
|
+
blocks.append({"type": "attachment", "attachment": _attachment_ref(path, ref=ref)})
|
|
107
|
+
accepted += 1
|
|
108
|
+
if not accepted:
|
|
109
|
+
return 0
|
|
110
|
+
with _lock:
|
|
111
|
+
queue = _pending.setdefault(session_id, [])
|
|
112
|
+
if len(queue) >= MAX_PENDING_PER_SESSION:
|
|
113
|
+
return 0
|
|
114
|
+
queue.append({"role": "user", "content": blocks, "__durable__": durable})
|
|
115
|
+
return accepted
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def drain_queued_images(session_id: str | None) -> tuple[list[LoopMessage], list[LoopMessage]]:
|
|
119
|
+
"""Take everything queued for this session (and clear it).
|
|
120
|
+
|
|
121
|
+
Returns ``(durable, ephemeral)`` — the caller persists the first group and appends the
|
|
122
|
+
second to this round's request only. The marker key is stripped so neither ever reaches
|
|
123
|
+
a provider payload.
|
|
124
|
+
"""
|
|
125
|
+
if not session_id:
|
|
126
|
+
return [], []
|
|
127
|
+
with _lock:
|
|
128
|
+
queued = _pending.pop(session_id, [])
|
|
129
|
+
durable: list[LoopMessage] = []
|
|
130
|
+
ephemeral: list[LoopMessage] = []
|
|
131
|
+
for item in queued:
|
|
132
|
+
keep = bool(item.pop("__durable__", True))
|
|
133
|
+
(durable if keep else ephemeral).append(item)
|
|
134
|
+
return durable, ephemeral
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def discard_queued_images(session_id: str | None) -> None:
|
|
138
|
+
"""Drop anything queued — used when a session ends so a killed run leaves nothing behind."""
|
|
139
|
+
if session_id:
|
|
140
|
+
with _lock:
|
|
141
|
+
_pending.pop(session_id, None)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _attachment_ref(path: str, *, ref: str = "") -> dict[str, Any]:
|
|
145
|
+
from power_loop._vendor.llm_client.multimodal import create_attachment_ref
|
|
146
|
+
|
|
147
|
+
return create_attachment_ref(path, ref=ref)
|
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
"""Put an image back in front of the model for ONE round.
|
|
2
|
-
|
|
3
|
-
Why this exists: a projection deliberately distils a past image down to a text reference
|
|
4
|
-
(``[image: shot.png]``) — keeping the picture would re-send it on every later send and the
|
|
5
|
-
token bill grows without bound. But an agent that later wants to LOOK at that image again has
|
|
6
|
-
nothing to look at.
|
|
7
|
-
|
|
8
|
-
Why it is not just a tool return value: the OpenAI-compatible protocol does not allow images
|
|
9
|
-
in a ``tool`` message — ``ChatCompletionToolMessageParam.content`` is typed as
|
|
10
|
-
``str | Iterable[ChatCompletionContentPartTextParam]``, text parts only. (Anthropic's
|
|
11
|
-
``tool_result`` does allow image blocks, but building on that would make history shape depend
|
|
12
|
-
on which vendor the definition happens to point at, and definitions switch models at runtime.)
|
|
13
|
-
So the image travels as its own **user** message instead, and the tool returns only a line
|
|
14
|
-
saying it is there.
|
|
15
|
-
|
|
16
|
-
Why EPHEMERAL: the queued message is appended to the round's request, never to the store. A
|
|
17
|
-
durable injection would write the image into history and the projection, so an agent that
|
|
18
|
-
recalled three times would carry three images forever — exactly the unbounded growth the
|
|
19
|
-
projection exists to prevent. One round, then gone; recall again if you need another look.
|
|
20
|
-
"""
|
|
21
|
-
|
|
22
|
-
from __future__ import annotations
|
|
23
|
-
|
|
24
|
-
import threading
|
|
25
|
-
from typing import Any
|
|
26
|
-
|
|
27
|
-
LoopMessage = dict[str, Any]
|
|
28
|
-
|
|
29
|
-
# session_id -> messages queued for the NEXT round of that session. Module-level rather than a
|
|
30
|
-
# contextvar because a tool handler may run in a worker thread (``asyncio.to_thread``), which
|
|
31
|
-
# does not inherit the caller's context.
|
|
32
|
-
_pending: dict[str, list[LoopMessage]] = {}
|
|
33
|
-
_lock = threading.Lock()
|
|
34
|
-
|
|
35
|
-
#: A queue is drained every round, so anything left is a tool that queued without a round
|
|
36
|
-
#: following. Cap it so a pathological loop cannot grow the dict without bound.
|
|
37
|
-
MAX_PENDING_PER_SESSION = 8
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
def queue_image_for_next_round(
|
|
41
|
-
session_id: str | None, *, path: str, note: str = "", ref: str = ""
|
|
42
|
-
) -> bool:
|
|
43
|
-
"""Queue one image (by local path) to be shown to the model on the next round.
|
|
44
|
-
|
|
45
|
-
Returns False when there is no session to queue against, or the queue is full — the caller
|
|
46
|
-
should then say so in its own return value rather than pretend the image was delivered.
|
|
47
|
-
"""
|
|
48
|
-
if not session_id or not path:
|
|
49
|
-
return False
|
|
50
|
-
blocks: list[dict[str, Any]] = []
|
|
51
|
-
if note:
|
|
52
|
-
blocks.append({"type": "text", "text": note})
|
|
53
|
-
# An `attachment` block (path, not bytes) — the renderer reads the file, applies the
|
|
54
|
-
# model's declared size limit and encodes it at request time.
|
|
55
|
-
blocks.append({
|
|
56
|
-
"type": "attachment",
|
|
57
|
-
# Carry the host's recall coordinate through: if the definition has meanwhile switched to
|
|
58
|
-
# a model that cannot see images, this is what the placeholder text will point at.
|
|
59
|
-
"attachment": _attachment_ref(path, ref=ref),
|
|
60
|
-
})
|
|
61
|
-
with _lock:
|
|
62
|
-
queue = _pending.setdefault(session_id, [])
|
|
63
|
-
if len(queue) >= MAX_PENDING_PER_SESSION:
|
|
64
|
-
return False
|
|
65
|
-
queue.append({"role": "user", "content": blocks})
|
|
66
|
-
return True
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
def drain_queued_images(session_id: str | None) -> list[LoopMessage]:
|
|
70
|
-
"""Take everything queued for this session (and clear it)."""
|
|
71
|
-
if not session_id:
|
|
72
|
-
return []
|
|
73
|
-
with _lock:
|
|
74
|
-
return _pending.pop(session_id, [])
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
def discard_queued_images(session_id: str | None) -> None:
|
|
78
|
-
"""Drop anything queued — used when a session ends so a killed run leaves nothing behind."""
|
|
79
|
-
if session_id:
|
|
80
|
-
with _lock:
|
|
81
|
-
_pending.pop(session_id, None)
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
def _attachment_ref(path: str, *, ref: str = "") -> dict[str, Any]:
|
|
85
|
-
from power_loop._vendor.llm_client.multimodal import create_attachment_ref
|
|
86
|
-
|
|
87
|
-
return create_attachment_ref(path, ref=ref)
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|