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.
Files changed (107) hide show
  1. {power_loop-6.2.0 → power_loop-6.4.0}/PKG-INFO +1 -1
  2. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/__init__.py +1 -1
  3. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/core/pipeline.py +12 -6
  4. power_loop-6.4.0/power_loop/runtime/image_recall.py +147 -0
  5. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop.egg-info/PKG-INFO +1 -1
  6. power_loop-6.2.0/power_loop/runtime/image_recall.py +0 -87
  7. {power_loop-6.2.0 → power_loop-6.4.0}/LICENSE +0 -0
  8. {power_loop-6.2.0 → power_loop-6.4.0}/README.md +0 -0
  9. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/_vendor/__init__.py +0 -0
  10. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/_vendor/llm_client/__init__.py +0 -0
  11. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/_vendor/llm_client/anthropic_factory.py +0 -0
  12. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/_vendor/llm_client/capabilities.py +0 -0
  13. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/_vendor/llm_client/interface.py +0 -0
  14. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/_vendor/llm_client/llm_factory.py +0 -0
  15. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/_vendor/llm_client/llm_tooling.py +0 -0
  16. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/_vendor/llm_client/llm_utils.py +0 -0
  17. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/_vendor/llm_client/multimodal.py +0 -0
  18. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/agent/__init__.py +0 -0
  19. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/agent/follow_up.py +0 -0
  20. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/agent/sink.py +0 -0
  21. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/agent/stateful_loop.py +0 -0
  22. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/agent/system_prompt.py +0 -0
  23. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/agent/types.py +0 -0
  24. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/contracts/__init__.py +0 -0
  25. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/contracts/errors.py +0 -0
  26. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/contracts/event_payloads.py +0 -0
  27. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/contracts/events.py +0 -0
  28. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/contracts/handlers.py +0 -0
  29. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/contracts/hook_contexts.py +0 -0
  30. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/contracts/hooks.py +0 -0
  31. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/contracts/messages.py +0 -0
  32. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/contracts/protocols.py +0 -0
  33. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/contracts/tools.py +0 -0
  34. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/contrib/__init__.py +0 -0
  35. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/contrib/_redact.py +0 -0
  36. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/contrib/jsonl_sink.py +0 -0
  37. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/contrib/logging_sink.py +0 -0
  38. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/contrib/mcp.py +0 -0
  39. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/contrib/metrics_sink.py +0 -0
  40. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/contrib/otel_sink.py +0 -0
  41. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/core/agent_context.py +0 -0
  42. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/core/events.py +0 -0
  43. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/core/hooks.py +0 -0
  44. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/core/phase.py +0 -0
  45. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/core/runner.py +0 -0
  46. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/core/state.py +0 -0
  47. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/py.typed +0 -0
  48. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/runtime/blackboard.py +0 -0
  49. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/runtime/budget.py +0 -0
  50. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/runtime/cancellation.py +0 -0
  51. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/runtime/compact.py +0 -0
  52. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/runtime/env.py +0 -0
  53. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/runtime/exec_backend.py +0 -0
  54. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/runtime/fold.py +0 -0
  55. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/runtime/fold_adapter.py +0 -0
  56. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/runtime/history_projector.py +0 -0
  57. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/runtime/history_sanitize.py +0 -0
  58. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/runtime/human_input.py +0 -0
  59. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/runtime/memory.py +0 -0
  60. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/runtime/notes.py +0 -0
  61. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/runtime/provider.py +0 -0
  62. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/runtime/representation.py +0 -0
  63. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/runtime/retry.py +0 -0
  64. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/runtime/runtime_state.py +0 -0
  65. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/runtime/session_store.py +0 -0
  66. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/runtime/skills.py +0 -0
  67. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/runtime/spec.py +0 -0
  68. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/runtime/store/__init__.py +0 -0
  69. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/runtime/store/backends/__init__.py +0 -0
  70. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/runtime/store/backends/mysql.py +0 -0
  71. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/runtime/store/backends/postgres.py +0 -0
  72. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/runtime/store/backends/sqlite.py +0 -0
  73. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/runtime/store/capabilities.py +0 -0
  74. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/runtime/store/db.py +0 -0
  75. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/runtime/store/dialect.py +0 -0
  76. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/runtime/store/factory.py +0 -0
  77. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/runtime/store/schema.py +0 -0
  78. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/runtime/store/store.py +0 -0
  79. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/runtime/store/types.py +0 -0
  80. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/runtime/structured.py +0 -0
  81. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/runtime/stub_provider.py +0 -0
  82. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/runtime/timers.py +0 -0
  83. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/tools/__init__.py +0 -0
  84. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/tools/blackboard.py +0 -0
  85. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/tools/command_policy.py +0 -0
  86. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/tools/default_manifest.py +0 -0
  87. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/tools/default_tools.py +0 -0
  88. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/tools/registry.py +0 -0
  89. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/tools/spawn_agent.py +0 -0
  90. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/workflow/__init__.py +0 -0
  91. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/workflow/api.py +0 -0
  92. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/workflow/engine.py +0 -0
  93. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/workflow/introspect.py +0 -0
  94. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/workflow/journal.py +0 -0
  95. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/workflow/result.py +0 -0
  96. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/workflow/resume.py +0 -0
  97. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/workflow/runner.py +0 -0
  98. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/workflow/spec.py +0 -0
  99. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/workflow/subprocess_executor.py +0 -0
  100. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/workflow/tool.py +0 -0
  101. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop/workflow/worker.py +0 -0
  102. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop.egg-info/SOURCES.txt +0 -0
  103. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop.egg-info/dependency_links.txt +0 -0
  104. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop.egg-info/requires.txt +0 -0
  105. {power_loop-6.2.0 → power_loop-6.4.0}/power_loop.egg-info/top_level.txt +0 -0
  106. {power_loop-6.2.0 → power_loop-6.4.0}/pyproject.toml +0 -0
  107. {power_loop-6.2.0 → power_loop-6.4.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: power-loop
3
- Version: 6.2.0
3
+ Version: 6.4.0
4
4
  Summary: Embeddable agent execution kernel — LLM loop, hooks, events, tools, dynamic sub-agents.
5
5
  Author-email: zhangran <zhangran24@126.com>
6
6
  License: MIT
@@ -15,7 +15,7 @@ Stability tiers
15
15
  无版本承诺,可随时变更或删除。
16
16
  """
17
17
 
18
- __version__ = "6.2.0"
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
- # EPHEMERAL image recall: a tool (e.g. recall_send hitting an image row) can put a
1048
- # picture back in front of the model for exactly ONE round. Appended to the request
1049
- # only — never persisted, so it does not enter history or the projection. Durable
1050
- # would mean an agent that recalled three times carries three images forever, which
1051
- # is the unbounded growth the projection exists to prevent.
1052
- for _img in drain_queued_images(self.session_id):
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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: power-loop
3
- Version: 6.2.0
3
+ Version: 6.4.0
4
4
  Summary: Embeddable agent execution kernel — LLM loop, hooks, events, tools, dynamic sub-agents.
5
5
  Author-email: zhangran <zhangran24@126.com>
6
6
  License: MIT
@@ -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