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
llm_client/llm_utils.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Lightweight LLM utilities (no LangChain dependency).
|
|
3
|
+
|
|
4
|
+
Goals:
|
|
5
|
+
- Provide a unified return object (raw_text / json_data / token_usage / errors)
|
|
6
|
+
- Provide robust JSON extraction similar to agent-psychology (strip fences, extract substring, fallback)
|
|
7
|
+
- Keep call sites (LangGraph nodes) minimal and consistent
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import re
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from .interface import LLMResponse
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def log_llm_json_result(
|
|
21
|
+
logger: logging.Logger,
|
|
22
|
+
result: LLMResponse,
|
|
23
|
+
*,
|
|
24
|
+
level: str = "info",
|
|
25
|
+
prefix: str = "",
|
|
26
|
+
include_debug: bool = False,
|
|
27
|
+
max_raw_chars: int = 600,
|
|
28
|
+
max_content_chars: int = 600,
|
|
29
|
+
) -> None:
|
|
30
|
+
"""
|
|
31
|
+
Convenience helper to log a result with a consistent format.
|
|
32
|
+
"""
|
|
33
|
+
msg = (prefix + " " if prefix else "") + result.to_log_str(
|
|
34
|
+
max_raw_chars=max_raw_chars,
|
|
35
|
+
max_content_chars=max_content_chars,
|
|
36
|
+
include_debug=include_debug,
|
|
37
|
+
)
|
|
38
|
+
fn = getattr(logger, level, None)
|
|
39
|
+
if not callable(fn):
|
|
40
|
+
fn = logger.info
|
|
41
|
+
fn(msg)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def strip_code_fences(text: str) -> str:
|
|
45
|
+
stripped = (text or "").strip()
|
|
46
|
+
if not stripped.startswith("```"):
|
|
47
|
+
return stripped
|
|
48
|
+
|
|
49
|
+
lines = stripped.splitlines()
|
|
50
|
+
if not lines:
|
|
51
|
+
return stripped
|
|
52
|
+
|
|
53
|
+
# drop first fence line (``` or ```json etc)
|
|
54
|
+
lines = lines[1:]
|
|
55
|
+
# drop trailing fences
|
|
56
|
+
while lines and lines[-1].strip() == "```":
|
|
57
|
+
lines = lines[:-1]
|
|
58
|
+
return "\n".join(lines).strip()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def extract_json_substring(text: str) -> str | None:
|
|
62
|
+
"""
|
|
63
|
+
Extract the first top-level JSON object substring by brace matching.
|
|
64
|
+
"""
|
|
65
|
+
if not text:
|
|
66
|
+
return None
|
|
67
|
+
start = text.find("{")
|
|
68
|
+
if start == -1:
|
|
69
|
+
return None
|
|
70
|
+
depth = 0
|
|
71
|
+
for i in range(start, len(text)):
|
|
72
|
+
c = text[i]
|
|
73
|
+
if c == "{":
|
|
74
|
+
depth += 1
|
|
75
|
+
elif c == "}":
|
|
76
|
+
depth -= 1
|
|
77
|
+
if depth == 0:
|
|
78
|
+
return text[start : i + 1]
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _try_json_loads(s: str) -> tuple[dict[str, Any] | None, str | None]:
|
|
83
|
+
try:
|
|
84
|
+
obj = json.loads(s)
|
|
85
|
+
if isinstance(obj, dict):
|
|
86
|
+
return obj, None
|
|
87
|
+
return None, "parsed_json_is_not_object"
|
|
88
|
+
except Exception as e:
|
|
89
|
+
return None, f"json_decode_error:{e}"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def parse_json_from_model_output(raw_text: str) -> tuple[dict[str, Any], str | None]:
|
|
93
|
+
"""
|
|
94
|
+
Parse JSON from model output with multi-step fallback.
|
|
95
|
+
Returns (json_data, error_message).
|
|
96
|
+
"""
|
|
97
|
+
text = (raw_text or "").strip()
|
|
98
|
+
if not text:
|
|
99
|
+
return {}, "empty_response"
|
|
100
|
+
|
|
101
|
+
# Strategy 1: direct parse
|
|
102
|
+
obj, err = _try_json_loads(text)
|
|
103
|
+
if obj is not None:
|
|
104
|
+
return obj, None
|
|
105
|
+
|
|
106
|
+
# Strategy 2: strip code fences
|
|
107
|
+
cleaned = strip_code_fences(text)
|
|
108
|
+
if cleaned != text:
|
|
109
|
+
obj2, err2 = _try_json_loads(cleaned)
|
|
110
|
+
if obj2 is not None:
|
|
111
|
+
return obj2, None
|
|
112
|
+
|
|
113
|
+
# Strategy 3: extract substring by braces
|
|
114
|
+
sub = extract_json_substring(cleaned)
|
|
115
|
+
if sub:
|
|
116
|
+
obj3, err3 = _try_json_loads(sub)
|
|
117
|
+
if obj3 is not None:
|
|
118
|
+
return obj3, None
|
|
119
|
+
|
|
120
|
+
# Strategy 4: simple repairs (trailing commas)
|
|
121
|
+
repaired = re.sub(r",\s*([}\]])", r"\1", sub or cleaned)
|
|
122
|
+
if repaired and repaired != (sub or cleaned):
|
|
123
|
+
obj4, err4 = _try_json_loads(repaired)
|
|
124
|
+
if obj4 is not None:
|
|
125
|
+
return obj4, None
|
|
126
|
+
|
|
127
|
+
return {}, err
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def parse_json_from_model_output_detailed(raw_text: str) -> LLMResponse:
|
|
131
|
+
"""
|
|
132
|
+
Detailed parser that returns a rich `LLMResponse` with intermediate artifacts.
|
|
133
|
+
This is the recommended API for new code.
|
|
134
|
+
"""
|
|
135
|
+
raw = (raw_text or "")
|
|
136
|
+
text = raw.strip()
|
|
137
|
+
res = LLMResponse(raw_text=raw)
|
|
138
|
+
|
|
139
|
+
if not text:
|
|
140
|
+
res.parse_error = "empty_response"
|
|
141
|
+
res.debug = {"strategy": "empty"}
|
|
142
|
+
return res
|
|
143
|
+
|
|
144
|
+
# Attempt raw direct parse (raw_json_data)
|
|
145
|
+
raw_obj, raw_err = _try_json_loads(text)
|
|
146
|
+
res.raw_json_data = raw_obj or {}
|
|
147
|
+
res.raw_json_error = raw_err
|
|
148
|
+
|
|
149
|
+
if raw_obj is not None:
|
|
150
|
+
# In this case, business json is the same as raw json.
|
|
151
|
+
res.content_text = text
|
|
152
|
+
res.json_data = raw_obj
|
|
153
|
+
res.parse_error = None
|
|
154
|
+
res.debug = {"strategy": "direct"}
|
|
155
|
+
return res
|
|
156
|
+
|
|
157
|
+
cleaned = strip_code_fences(text)
|
|
158
|
+
res.debug["cleaned_text"] = cleaned
|
|
159
|
+
|
|
160
|
+
# Try cleaned direct parse
|
|
161
|
+
if cleaned != text:
|
|
162
|
+
obj2, err2 = _try_json_loads(cleaned)
|
|
163
|
+
if obj2 is not None:
|
|
164
|
+
res.content_text = cleaned
|
|
165
|
+
res.json_data = obj2
|
|
166
|
+
res.parse_error = None
|
|
167
|
+
res.debug["strategy"] = "strip_code_fences"
|
|
168
|
+
return res
|
|
169
|
+
|
|
170
|
+
# Extract substring
|
|
171
|
+
sub = extract_json_substring(cleaned)
|
|
172
|
+
res.debug["json_substring"] = sub or ""
|
|
173
|
+
if sub:
|
|
174
|
+
obj3, err3 = _try_json_loads(sub)
|
|
175
|
+
if obj3 is not None:
|
|
176
|
+
res.content_text = sub
|
|
177
|
+
res.json_data = obj3
|
|
178
|
+
res.parse_error = None
|
|
179
|
+
res.debug["strategy"] = "extract_substring"
|
|
180
|
+
return res
|
|
181
|
+
|
|
182
|
+
# Repairs: trailing commas
|
|
183
|
+
candidate = sub or cleaned
|
|
184
|
+
repaired = re.sub(r",\s*([}\]])", r"\1", candidate)
|
|
185
|
+
res.debug["repaired_text"] = repaired
|
|
186
|
+
if repaired and repaired != candidate:
|
|
187
|
+
obj4, err4 = _try_json_loads(repaired)
|
|
188
|
+
if obj4 is not None:
|
|
189
|
+
res.content_text = repaired
|
|
190
|
+
res.json_data = obj4
|
|
191
|
+
res.parse_error = None
|
|
192
|
+
res.debug["strategy"] = "repair_trailing_commas"
|
|
193
|
+
return res
|
|
194
|
+
|
|
195
|
+
# All failed
|
|
196
|
+
res.content_text = candidate
|
|
197
|
+
res.json_data = {}
|
|
198
|
+
res.parse_error = raw_err or "unable_to_parse_json"
|
|
199
|
+
res.debug["strategy"] = "failed"
|
|
200
|
+
return res
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# Note: do not add extra aliases here; keep naming consistent at the interfaces layer.
|
|
204
|
+
|
|
205
|
+
|
llm_client/multimodal.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import mimetypes
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .capabilities import ModelCapabilities
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
from pypdf import PdfReader
|
|
13
|
+
except Exception: # pragma: no cover
|
|
14
|
+
PdfReader = None # type: ignore[assignment,misc]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
MAX_PDF_TEXT_CHARS = 24_000
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class AttachmentRef:
|
|
22
|
+
path: str
|
|
23
|
+
filename: str
|
|
24
|
+
mime_type: str
|
|
25
|
+
kind: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class PreparedAttachment:
|
|
30
|
+
ref: AttachmentRef
|
|
31
|
+
text_fallback: str = ""
|
|
32
|
+
rendered_parts: tuple[dict[str, Any], ...] = ()
|
|
33
|
+
strategy: str = "text"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _guess_mime_type(path: Path) -> str:
|
|
37
|
+
mime_type, _ = mimetypes.guess_type(str(path))
|
|
38
|
+
return mime_type or "application/octet-stream"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def create_attachment_ref(path: str | Path) -> dict[str, Any]:
|
|
42
|
+
file_path = Path(path).expanduser().resolve()
|
|
43
|
+
mime_type = _guess_mime_type(file_path)
|
|
44
|
+
kind = "other"
|
|
45
|
+
if mime_type.startswith("image/"):
|
|
46
|
+
kind = "image"
|
|
47
|
+
elif mime_type == "application/pdf":
|
|
48
|
+
kind = "pdf"
|
|
49
|
+
ref = AttachmentRef(
|
|
50
|
+
path=str(file_path),
|
|
51
|
+
filename=file_path.name,
|
|
52
|
+
mime_type=mime_type,
|
|
53
|
+
kind=kind,
|
|
54
|
+
)
|
|
55
|
+
return ref.__dict__.copy()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def extract_text_from_content(content: Any) -> str:
|
|
59
|
+
if isinstance(content, str):
|
|
60
|
+
return content
|
|
61
|
+
if not isinstance(content, list):
|
|
62
|
+
return str(content or "")
|
|
63
|
+
|
|
64
|
+
parts: list[str] = []
|
|
65
|
+
for block in content:
|
|
66
|
+
if isinstance(block, str):
|
|
67
|
+
parts.append(block)
|
|
68
|
+
continue
|
|
69
|
+
if not isinstance(block, dict):
|
|
70
|
+
parts.append(str(block))
|
|
71
|
+
continue
|
|
72
|
+
|
|
73
|
+
block_type = block.get("type")
|
|
74
|
+
if block_type == "text":
|
|
75
|
+
parts.append(str(block.get("text") or ""))
|
|
76
|
+
continue
|
|
77
|
+
if block_type == "attachment":
|
|
78
|
+
attachment = block.get("attachment") or {}
|
|
79
|
+
filename = attachment.get("filename") or Path(str(attachment.get("path") or "attachment")).name
|
|
80
|
+
parts.append(f"[Attached file: {filename}]")
|
|
81
|
+
continue
|
|
82
|
+
if "text" in block:
|
|
83
|
+
parts.append(str(block.get("text") or ""))
|
|
84
|
+
return "\n\n".join(part for part in parts if part).strip()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _file_to_data_url(path: Path, mime_type: str) -> str:
|
|
88
|
+
payload = base64.b64encode(path.read_bytes()).decode("ascii")
|
|
89
|
+
return f"data:{mime_type};base64,{payload}"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _extract_pdf_text(path: Path) -> str:
|
|
93
|
+
if PdfReader is None:
|
|
94
|
+
return ""
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
reader = PdfReader(str(path))
|
|
98
|
+
except Exception:
|
|
99
|
+
return ""
|
|
100
|
+
|
|
101
|
+
pages: list[str] = []
|
|
102
|
+
for index, page in enumerate(reader.pages, start=1):
|
|
103
|
+
text = ""
|
|
104
|
+
try:
|
|
105
|
+
text = (page.extract_text() or "").strip()
|
|
106
|
+
except Exception:
|
|
107
|
+
text = ""
|
|
108
|
+
if text:
|
|
109
|
+
pages.append(f"[Page {index}]\n{text}")
|
|
110
|
+
|
|
111
|
+
return "\n\n".join(pages)[:MAX_PDF_TEXT_CHARS]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _render_image_attachment(ref: AttachmentRef, path: Path, capabilities: ModelCapabilities) -> PreparedAttachment:
|
|
115
|
+
if capabilities.supports_image_input and capabilities.supports_data_url:
|
|
116
|
+
part = {
|
|
117
|
+
"type": "image_url",
|
|
118
|
+
"image_url": {
|
|
119
|
+
"url": _file_to_data_url(path, ref.mime_type),
|
|
120
|
+
},
|
|
121
|
+
}
|
|
122
|
+
return PreparedAttachment(ref=ref, rendered_parts=(part,), strategy="native-image")
|
|
123
|
+
|
|
124
|
+
text = (
|
|
125
|
+
f"[Attached image: {ref.filename}]\n"
|
|
126
|
+
"The current model does not support image input, so the image was not sent natively."
|
|
127
|
+
)
|
|
128
|
+
return PreparedAttachment(
|
|
129
|
+
ref=ref,
|
|
130
|
+
text_fallback=text,
|
|
131
|
+
rendered_parts=({"type": "text", "text": text},),
|
|
132
|
+
strategy="image-unsupported",
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _render_pdf_attachment(ref: AttachmentRef, path: Path, capabilities: ModelCapabilities) -> PreparedAttachment:
|
|
137
|
+
extracted_text = _extract_pdf_text(path)
|
|
138
|
+
|
|
139
|
+
if capabilities.supports_pdf_input_chat:
|
|
140
|
+
text = (
|
|
141
|
+
f"[Attached PDF: {ref.filename}]\n"
|
|
142
|
+
"Native PDF chat transmission is not enabled in this build, so extracted text fallback was used instead."
|
|
143
|
+
)
|
|
144
|
+
if extracted_text:
|
|
145
|
+
text = f"{text}\n\n{extracted_text}"
|
|
146
|
+
return PreparedAttachment(
|
|
147
|
+
ref=ref,
|
|
148
|
+
text_fallback=text,
|
|
149
|
+
rendered_parts=({"type": "text", "text": text},),
|
|
150
|
+
strategy="pdf-fallback-text",
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
if extracted_text:
|
|
154
|
+
text = f"[Attached PDF: {ref.filename}]\n\n{extracted_text}"
|
|
155
|
+
return PreparedAttachment(
|
|
156
|
+
ref=ref,
|
|
157
|
+
text_fallback=text,
|
|
158
|
+
rendered_parts=({"type": "text", "text": text},),
|
|
159
|
+
strategy="pdf-extracted-text",
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
text = (
|
|
163
|
+
f"[Attached PDF: {ref.filename}]\n"
|
|
164
|
+
"No readable text could be extracted from this PDF, and the current model/path does not support native PDF input."
|
|
165
|
+
)
|
|
166
|
+
return PreparedAttachment(
|
|
167
|
+
ref=ref,
|
|
168
|
+
text_fallback=text,
|
|
169
|
+
rendered_parts=({"type": "text", "text": text},),
|
|
170
|
+
strategy="pdf-unreadable",
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def prepare_attachment(ref_payload: dict[str, Any], capabilities: ModelCapabilities) -> PreparedAttachment:
|
|
175
|
+
ref = AttachmentRef(
|
|
176
|
+
path=str(ref_payload.get("path") or ""),
|
|
177
|
+
filename=str(ref_payload.get("filename") or Path(str(ref_payload.get("path") or "attachment")).name),
|
|
178
|
+
mime_type=str(ref_payload.get("mime_type") or "application/octet-stream"),
|
|
179
|
+
kind=str(ref_payload.get("kind") or "other"),
|
|
180
|
+
)
|
|
181
|
+
path = Path(ref.path)
|
|
182
|
+
|
|
183
|
+
if ref.kind == "image":
|
|
184
|
+
return _render_image_attachment(ref, path, capabilities)
|
|
185
|
+
if ref.kind == "pdf":
|
|
186
|
+
return _render_pdf_attachment(ref, path, capabilities)
|
|
187
|
+
|
|
188
|
+
text = f"[Attached file: {ref.filename}] Unsupported attachment type: {ref.mime_type}"
|
|
189
|
+
return PreparedAttachment(
|
|
190
|
+
ref=ref,
|
|
191
|
+
text_fallback=text,
|
|
192
|
+
rendered_parts=({"type": "text", "text": text},),
|
|
193
|
+
strategy="unsupported-file",
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def render_message_content(content: Any, role: str, capabilities: ModelCapabilities) -> Any:
|
|
198
|
+
if not isinstance(content, list) or role != "user":
|
|
199
|
+
return content
|
|
200
|
+
|
|
201
|
+
rendered: list[dict[str, Any]] = []
|
|
202
|
+
debug_strategies: list[str] = []
|
|
203
|
+
for block in content:
|
|
204
|
+
if isinstance(block, str):
|
|
205
|
+
rendered.append({"type": "text", "text": block})
|
|
206
|
+
continue
|
|
207
|
+
if not isinstance(block, dict):
|
|
208
|
+
rendered.append({"type": "text", "text": str(block)})
|
|
209
|
+
continue
|
|
210
|
+
|
|
211
|
+
block_type = block.get("type")
|
|
212
|
+
if block_type == "text":
|
|
213
|
+
text = str(block.get("text") or "")
|
|
214
|
+
if text:
|
|
215
|
+
rendered.append({"type": "text", "text": text})
|
|
216
|
+
continue
|
|
217
|
+
|
|
218
|
+
if block_type == "attachment":
|
|
219
|
+
prepared = prepare_attachment(block.get("attachment") or {}, capabilities)
|
|
220
|
+
debug_strategies.append(prepared.strategy)
|
|
221
|
+
rendered.extend(prepared.rendered_parts)
|
|
222
|
+
continue
|
|
223
|
+
|
|
224
|
+
rendered.append(block)
|
|
225
|
+
|
|
226
|
+
text_parts = [part.get("text", "") for part in rendered if isinstance(part, dict) and part.get("type") == "text"]
|
|
227
|
+
non_text_parts = [part for part in rendered if not (isinstance(part, dict) and part.get("type") == "text")]
|
|
228
|
+
|
|
229
|
+
if not non_text_parts:
|
|
230
|
+
return "\n\n".join(text for text in text_parts if text).strip()
|
|
231
|
+
|
|
232
|
+
if text_parts:
|
|
233
|
+
merged: list[dict[str, Any]] = [{"type": "text", "text": "\n\n".join(text for text in text_parts if text).strip()}]
|
|
234
|
+
merged.extend(non_text_parts)
|
|
235
|
+
return [part for part in merged if not (part.get("type") == "text" and not part.get("text"))]
|
|
236
|
+
|
|
237
|
+
return non_text_parts
|