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,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
+
@@ -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