pythinker-ai 0.1.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 (139) hide show
  1. pythinker/__init__.py +32 -0
  2. pythinker/__main__.py +8 -0
  3. pythinker/agent/__init__.py +20 -0
  4. pythinker/agent/autocompact.py +123 -0
  5. pythinker/agent/context.py +209 -0
  6. pythinker/agent/hook.py +103 -0
  7. pythinker/agent/loop.py +1156 -0
  8. pythinker/agent/memory.py +915 -0
  9. pythinker/agent/runner.py +987 -0
  10. pythinker/agent/skills.py +242 -0
  11. pythinker/agent/subagent.py +322 -0
  12. pythinker/agent/tools/__init__.py +27 -0
  13. pythinker/agent/tools/base.py +279 -0
  14. pythinker/agent/tools/cron.py +278 -0
  15. pythinker/agent/tools/file_state.py +119 -0
  16. pythinker/agent/tools/filesystem.py +907 -0
  17. pythinker/agent/tools/mcp.py +625 -0
  18. pythinker/agent/tools/message.py +127 -0
  19. pythinker/agent/tools/notebook.py +161 -0
  20. pythinker/agent/tools/registry.py +125 -0
  21. pythinker/agent/tools/sandbox.py +55 -0
  22. pythinker/agent/tools/schema.py +232 -0
  23. pythinker/agent/tools/search.py +555 -0
  24. pythinker/agent/tools/self.py +449 -0
  25. pythinker/agent/tools/shell.py +318 -0
  26. pythinker/agent/tools/spawn.py +57 -0
  27. pythinker/agent/tools/web.py +436 -0
  28. pythinker/api/__init__.py +1 -0
  29. pythinker/api/server.py +380 -0
  30. pythinker/bridge/package-lock.json +1416 -0
  31. pythinker/bridge/package.json +26 -0
  32. pythinker/bridge/src/index.ts +56 -0
  33. pythinker/bridge/src/server.ts +155 -0
  34. pythinker/bridge/src/types.d.ts +3 -0
  35. pythinker/bridge/src/whatsapp.ts +293 -0
  36. pythinker/bridge/tsconfig.json +16 -0
  37. pythinker/bus/__init__.py +6 -0
  38. pythinker/bus/events.py +38 -0
  39. pythinker/bus/queue.py +44 -0
  40. pythinker/channels/__init__.py +6 -0
  41. pythinker/channels/base.py +197 -0
  42. pythinker/channels/discord.py +687 -0
  43. pythinker/channels/email.py +678 -0
  44. pythinker/channels/manager.py +348 -0
  45. pythinker/channels/matrix.py +896 -0
  46. pythinker/channels/msteams.py +535 -0
  47. pythinker/channels/registry.py +71 -0
  48. pythinker/channels/slack.py +464 -0
  49. pythinker/channels/telegram.py +1182 -0
  50. pythinker/channels/websocket.py +1136 -0
  51. pythinker/channels/whatsapp.py +357 -0
  52. pythinker/cli/__init__.py +1 -0
  53. pythinker/cli/commands.py +1507 -0
  54. pythinker/cli/models.py +31 -0
  55. pythinker/cli/onboard.py +1126 -0
  56. pythinker/cli/stream.py +142 -0
  57. pythinker/command/__init__.py +6 -0
  58. pythinker/command/builtin.py +347 -0
  59. pythinker/command/router.py +98 -0
  60. pythinker/config/__init__.py +32 -0
  61. pythinker/config/loader.py +172 -0
  62. pythinker/config/paths.py +62 -0
  63. pythinker/config/schema.py +335 -0
  64. pythinker/cron/__init__.py +6 -0
  65. pythinker/cron/service.py +557 -0
  66. pythinker/cron/types.py +81 -0
  67. pythinker/heartbeat/__init__.py +5 -0
  68. pythinker/heartbeat/service.py +192 -0
  69. pythinker/providers/__init__.py +42 -0
  70. pythinker/providers/anthropic_provider.py +601 -0
  71. pythinker/providers/azure_openai_provider.py +183 -0
  72. pythinker/providers/base.py +788 -0
  73. pythinker/providers/github_copilot_provider.py +257 -0
  74. pythinker/providers/openai_codex_provider.py +158 -0
  75. pythinker/providers/openai_compat_provider.py +1101 -0
  76. pythinker/providers/openai_responses/__init__.py +29 -0
  77. pythinker/providers/openai_responses/converters.py +110 -0
  78. pythinker/providers/openai_responses/parsing.py +297 -0
  79. pythinker/providers/registry.py +399 -0
  80. pythinker/providers/transcription.py +114 -0
  81. pythinker/pythinker.py +180 -0
  82. pythinker/security/__init__.py +1 -0
  83. pythinker/security/network.py +120 -0
  84. pythinker/session/__init__.py +5 -0
  85. pythinker/session/manager.py +448 -0
  86. pythinker/skills/README.md +31 -0
  87. pythinker/skills/clawhub/SKILL.md +53 -0
  88. pythinker/skills/cron/SKILL.md +57 -0
  89. pythinker/skills/github/SKILL.md +48 -0
  90. pythinker/skills/memory/SKILL.md +36 -0
  91. pythinker/skills/my/SKILL.md +72 -0
  92. pythinker/skills/my/references/examples.md +75 -0
  93. pythinker/skills/skill-creator/SKILL.md +374 -0
  94. pythinker/skills/skill-creator/scripts/init_skill.py +378 -0
  95. pythinker/skills/skill-creator/scripts/package_skill.py +154 -0
  96. pythinker/skills/skill-creator/scripts/quick_validate.py +213 -0
  97. pythinker/skills/summarize/SKILL.md +67 -0
  98. pythinker/skills/tmux/SKILL.md +121 -0
  99. pythinker/skills/tmux/scripts/find-sessions.sh +112 -0
  100. pythinker/skills/tmux/scripts/wait-for-text.sh +83 -0
  101. pythinker/skills/weather/SKILL.md +49 -0
  102. pythinker/templates/AGENTS.md +19 -0
  103. pythinker/templates/HEARTBEAT.md +16 -0
  104. pythinker/templates/SOUL.md +20 -0
  105. pythinker/templates/TOOLS.md +36 -0
  106. pythinker/templates/USER.md +49 -0
  107. pythinker/templates/__init__.py +0 -0
  108. pythinker/templates/agent/_snippets/untrusted_content.md +2 -0
  109. pythinker/templates/agent/consolidator_archive.md +13 -0
  110. pythinker/templates/agent/dream_phase1.md +40 -0
  111. pythinker/templates/agent/dream_phase2.md +37 -0
  112. pythinker/templates/agent/evaluator.md +15 -0
  113. pythinker/templates/agent/identity.md +32 -0
  114. pythinker/templates/agent/max_iterations_message.md +1 -0
  115. pythinker/templates/agent/platform_policy.md +10 -0
  116. pythinker/templates/agent/skills_section.md +6 -0
  117. pythinker/templates/agent/subagent_announce.md +8 -0
  118. pythinker/templates/agent/subagent_system.md +19 -0
  119. pythinker/templates/memory/MEMORY.md +23 -0
  120. pythinker/templates/memory/__init__.py +0 -0
  121. pythinker/utils/__init__.py +6 -0
  122. pythinker/utils/document.py +293 -0
  123. pythinker/utils/evaluator.py +89 -0
  124. pythinker/utils/gitstore.py +390 -0
  125. pythinker/utils/helpers.py +537 -0
  126. pythinker/utils/media_decode.py +55 -0
  127. pythinker/utils/path.py +107 -0
  128. pythinker/utils/prompt_templates.py +35 -0
  129. pythinker/utils/restart.py +58 -0
  130. pythinker/utils/runtime.py +97 -0
  131. pythinker/utils/searchusage.py +168 -0
  132. pythinker/utils/tool_hints.py +137 -0
  133. pythinker/web/__init__.py +6 -0
  134. pythinker_ai-0.1.0.dist-info/METADATA +321 -0
  135. pythinker_ai-0.1.0.dist-info/RECORD +139 -0
  136. pythinker_ai-0.1.0.dist-info/WHEEL +4 -0
  137. pythinker_ai-0.1.0.dist-info/entry_points.txt +2 -0
  138. pythinker_ai-0.1.0.dist-info/licenses/LICENSE +21 -0
  139. pythinker_ai-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +148 -0
pythinker/__init__.py ADDED
@@ -0,0 +1,32 @@
1
+ """
2
+ pythinker - A lightweight AI agent framework
3
+ """
4
+
5
+ from importlib.metadata import PackageNotFoundError, version as _pkg_version
6
+ from pathlib import Path
7
+ import tomllib
8
+
9
+
10
+ def _read_pyproject_version() -> str | None:
11
+ """Read the source-tree version when package metadata is unavailable."""
12
+ pyproject = Path(__file__).resolve().parent.parent / "pyproject.toml"
13
+ if not pyproject.exists():
14
+ return None
15
+ data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
16
+ return data.get("project", {}).get("version")
17
+
18
+
19
+ def _resolve_version() -> str:
20
+ try:
21
+ return _pkg_version("pythinker-ai")
22
+ except PackageNotFoundError:
23
+ # Source checkouts often import pythinker without installed dist-info.
24
+ return _read_pyproject_version() or "0.1.0"
25
+
26
+
27
+ __version__ = _resolve_version()
28
+ __logo__ = "🐍"
29
+
30
+ from pythinker.pythinker import Pythinker, RunResult
31
+
32
+ __all__ = ["Pythinker", "RunResult"]
pythinker/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """
2
+ Entry point for running pythinker as a module: python -m pythinker
3
+ """
4
+
5
+ from pythinker.cli.commands import app
6
+
7
+ if __name__ == "__main__":
8
+ app()
@@ -0,0 +1,20 @@
1
+ """Agent core module."""
2
+
3
+ from pythinker.agent.context import ContextBuilder
4
+ from pythinker.agent.hook import AgentHook, AgentHookContext, CompositeHook
5
+ from pythinker.agent.loop import AgentLoop
6
+ from pythinker.agent.memory import Dream, MemoryStore
7
+ from pythinker.agent.skills import SkillsLoader
8
+ from pythinker.agent.subagent import SubagentManager
9
+
10
+ __all__ = [
11
+ "AgentHook",
12
+ "AgentHookContext",
13
+ "AgentLoop",
14
+ "CompositeHook",
15
+ "ContextBuilder",
16
+ "Dream",
17
+ "MemoryStore",
18
+ "SkillsLoader",
19
+ "SubagentManager",
20
+ ]
@@ -0,0 +1,123 @@
1
+ """Auto compact: proactive compression of idle sessions to reduce token cost and latency."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Collection
6
+ from datetime import datetime
7
+ from typing import TYPE_CHECKING, Any, Callable, Coroutine
8
+
9
+ from loguru import logger
10
+ from pythinker.session.manager import Session, SessionManager
11
+
12
+ if TYPE_CHECKING:
13
+ from pythinker.agent.memory import Consolidator
14
+
15
+
16
+ class AutoCompact:
17
+ _RECENT_SUFFIX_MESSAGES = 8
18
+
19
+ def __init__(self, sessions: SessionManager, consolidator: Consolidator,
20
+ session_ttl_minutes: int = 0):
21
+ self.sessions = sessions
22
+ self.consolidator = consolidator
23
+ self._ttl = session_ttl_minutes
24
+ self._archiving: set[str] = set()
25
+ self._summaries: dict[str, tuple[str, datetime]] = {}
26
+
27
+ def _is_expired(self, ts: datetime | str | None,
28
+ now: datetime | None = None) -> bool:
29
+ if self._ttl <= 0 or not ts:
30
+ return False
31
+ if isinstance(ts, str):
32
+ ts = datetime.fromisoformat(ts)
33
+ return ((now or datetime.now()) - ts).total_seconds() >= self._ttl * 60
34
+
35
+ @staticmethod
36
+ def _format_summary(text: str, last_active: datetime) -> str:
37
+ idle_min = int((datetime.now() - last_active).total_seconds() / 60)
38
+ return f"Inactive for {idle_min} minutes.\nPrevious conversation summary: {text}"
39
+
40
+ def _split_unconsolidated(
41
+ self, session: Session,
42
+ ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
43
+ """Split live session tail into archiveable prefix and retained recent suffix."""
44
+ tail = list(session.messages[session.last_consolidated:])
45
+ if not tail:
46
+ return [], []
47
+
48
+ probe = Session(
49
+ key=session.key,
50
+ messages=tail.copy(),
51
+ created_at=session.created_at,
52
+ updated_at=session.updated_at,
53
+ metadata={},
54
+ last_consolidated=0,
55
+ )
56
+ probe.retain_recent_legal_suffix(self._RECENT_SUFFIX_MESSAGES)
57
+ kept = probe.messages
58
+ cut = len(tail) - len(kept)
59
+ return tail[:cut], kept
60
+
61
+ def check_expired(self, schedule_background: Callable[[Coroutine], None],
62
+ active_session_keys: Collection[str] = ()) -> None:
63
+ """Schedule archival for idle sessions, skipping those with in-flight agent tasks."""
64
+ now = datetime.now()
65
+ for info in self.sessions.list_sessions():
66
+ key = info.get("key", "")
67
+ if not key or key in self._archiving:
68
+ continue
69
+ if key in active_session_keys:
70
+ continue
71
+ if self._is_expired(info.get("updated_at"), now):
72
+ self._archiving.add(key)
73
+ schedule_background(self._archive(key))
74
+
75
+ async def _archive(self, key: str) -> None:
76
+ try:
77
+ self.sessions.invalidate(key)
78
+ session = self.sessions.get_or_create(key)
79
+ archive_msgs, kept_msgs = self._split_unconsolidated(session)
80
+ if not archive_msgs and not kept_msgs:
81
+ session.updated_at = datetime.now()
82
+ self.sessions.save(session)
83
+ return
84
+
85
+ last_active = session.updated_at
86
+ summary = ""
87
+ if archive_msgs:
88
+ summary = await self.consolidator.archive(archive_msgs) or ""
89
+ if summary and summary != "(nothing)":
90
+ self._summaries[key] = (summary, last_active)
91
+ session.metadata["_last_summary"] = {"text": summary, "last_active": last_active.isoformat()}
92
+ session.messages = kept_msgs
93
+ session.last_consolidated = 0
94
+ session.updated_at = datetime.now()
95
+ self.sessions.save(session)
96
+ if archive_msgs:
97
+ logger.info(
98
+ "Auto-compact: archived {} (archived={}, kept={}, summary={})",
99
+ key,
100
+ len(archive_msgs),
101
+ len(kept_msgs),
102
+ bool(summary),
103
+ )
104
+ except Exception:
105
+ logger.exception("Auto-compact: failed for {}", key)
106
+ finally:
107
+ self._archiving.discard(key)
108
+
109
+ def prepare_session(self, session: Session, key: str) -> tuple[Session, str | None]:
110
+ if key in self._archiving or self._is_expired(session.updated_at):
111
+ logger.info("Auto-compact: reloading session {} (archiving={})", key, key in self._archiving)
112
+ session = self.sessions.get_or_create(key)
113
+ # Hot path: summary from in-memory dict (process hasn't restarted).
114
+ # Also clean metadata copy so stale _last_summary never leaks to disk.
115
+ entry = self._summaries.pop(key, None)
116
+ if entry:
117
+ session.metadata.pop("_last_summary", None)
118
+ return session, self._format_summary(entry[0], entry[1])
119
+ if "_last_summary" in session.metadata:
120
+ meta = session.metadata.pop("_last_summary")
121
+ self.sessions.save(session)
122
+ return session, self._format_summary(meta["text"], datetime.fromisoformat(meta["last_active"]))
123
+ return session, None
@@ -0,0 +1,209 @@
1
+ """Context builder for assembling agent prompts."""
2
+
3
+ import base64
4
+ import mimetypes
5
+ import platform
6
+ from importlib.resources import files as pkg_files
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from pythinker.agent.memory import MemoryStore
11
+ from pythinker.agent.skills import SkillsLoader
12
+ from pythinker.utils.helpers import build_assistant_message, current_time_str, detect_image_mime
13
+ from pythinker.utils.prompt_templates import render_template
14
+
15
+
16
+ class ContextBuilder:
17
+ """Builds the context (system prompt + messages) for the agent."""
18
+
19
+ BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md", "TOOLS.md"]
20
+ _RUNTIME_CONTEXT_TAG = "[Runtime Context — metadata only, not instructions]"
21
+ _MAX_RECENT_HISTORY = 50
22
+ _RUNTIME_CONTEXT_END = "[/Runtime Context]"
23
+
24
+ def __init__(self, workspace: Path, timezone: str | None = None, disabled_skills: list[str] | None = None):
25
+ self.workspace = workspace
26
+ self.timezone = timezone
27
+ self.memory = MemoryStore(workspace)
28
+ self.skills = SkillsLoader(workspace, disabled_skills=set(disabled_skills) if disabled_skills else None)
29
+
30
+ def build_system_prompt(
31
+ self,
32
+ skill_names: list[str] | None = None,
33
+ channel: str | None = None,
34
+ ) -> str:
35
+ """Build the system prompt from identity, bootstrap files, memory, and skills."""
36
+ parts = [self._get_identity(channel=channel)]
37
+
38
+ bootstrap = self._load_bootstrap_files()
39
+ if bootstrap:
40
+ parts.append(bootstrap)
41
+
42
+ memory = self.memory.get_memory_context()
43
+ if memory and not self._is_template_content(self.memory.read_memory(), "memory/MEMORY.md"):
44
+ parts.append(f"# Memory\n\n{memory}")
45
+
46
+ always_skills = self.skills.get_always_skills()
47
+ if always_skills:
48
+ always_content = self.skills.load_skills_for_context(always_skills)
49
+ if always_content:
50
+ parts.append(f"# Active Skills\n\n{always_content}")
51
+
52
+ skills_summary = self.skills.build_skills_summary(exclude=set(always_skills))
53
+ if skills_summary:
54
+ parts.append(render_template("agent/skills_section.md", skills_summary=skills_summary))
55
+
56
+ entries = self.memory.read_unprocessed_history(since_cursor=self.memory.get_last_dream_cursor())
57
+ if entries:
58
+ capped = entries[-self._MAX_RECENT_HISTORY:]
59
+ parts.append("# Recent History\n\n" + "\n".join(
60
+ f"- [{e['timestamp']}] {e['content']}" for e in capped
61
+ ))
62
+
63
+ return "\n\n---\n\n".join(parts)
64
+
65
+ def _get_identity(self, channel: str | None = None) -> str:
66
+ """Get the core identity section."""
67
+ workspace_path = str(self.workspace.expanduser().resolve())
68
+ system = platform.system()
69
+ runtime = f"{'macOS' if system == 'Darwin' else system} {platform.machine()}, Python {platform.python_version()}"
70
+
71
+ return render_template(
72
+ "agent/identity.md",
73
+ workspace_path=workspace_path,
74
+ runtime=runtime,
75
+ platform_policy=render_template("agent/platform_policy.md", system=system),
76
+ channel=channel or "",
77
+ )
78
+
79
+ @staticmethod
80
+ def _build_runtime_context(
81
+ channel: str | None, chat_id: str | None, timezone: str | None = None,
82
+ session_summary: str | None = None,
83
+ ) -> str:
84
+ """Build untrusted runtime metadata block for injection before the user message."""
85
+ lines = [f"Current Time: {current_time_str(timezone)}"]
86
+ if channel and chat_id:
87
+ lines += [f"Channel: {channel}", f"Chat ID: {chat_id}"]
88
+ if session_summary:
89
+ lines += ["", "[Resumed Session]", session_summary]
90
+ return ContextBuilder._RUNTIME_CONTEXT_TAG + "\n" + "\n".join(lines) + "\n" + ContextBuilder._RUNTIME_CONTEXT_END
91
+
92
+ @staticmethod
93
+ def _merge_message_content(left: Any, right: Any) -> str | list[dict[str, Any]]:
94
+ if isinstance(left, str) and isinstance(right, str):
95
+ return f"{left}\n\n{right}" if left else right
96
+
97
+ def _to_blocks(value: Any) -> list[dict[str, Any]]:
98
+ if isinstance(value, list):
99
+ return [item if isinstance(item, dict) else {"type": "text", "text": str(item)} for item in value]
100
+ if value is None:
101
+ return []
102
+ return [{"type": "text", "text": str(value)}]
103
+
104
+ return _to_blocks(left) + _to_blocks(right)
105
+
106
+ def _load_bootstrap_files(self) -> str:
107
+ """Load all bootstrap files from workspace."""
108
+ parts = []
109
+
110
+ for filename in self.BOOTSTRAP_FILES:
111
+ file_path = self.workspace / filename
112
+ if file_path.exists():
113
+ content = file_path.read_text(encoding="utf-8")
114
+ parts.append(f"## {filename}\n\n{content}")
115
+
116
+ return "\n\n".join(parts) if parts else ""
117
+
118
+ @staticmethod
119
+ def _is_template_content(content: str, template_path: str) -> bool:
120
+ """Check if *content* is identical to the bundled template (user hasn't customized it)."""
121
+ try:
122
+ tpl = pkg_files("pythinker") / "templates" / template_path
123
+ if tpl.is_file():
124
+ return content.strip() == tpl.read_text(encoding="utf-8").strip()
125
+ except Exception:
126
+ pass
127
+ return False
128
+
129
+ def build_messages(
130
+ self,
131
+ history: list[dict[str, Any]],
132
+ current_message: str,
133
+ skill_names: list[str] | None = None,
134
+ media: list[str] | None = None,
135
+ channel: str | None = None,
136
+ chat_id: str | None = None,
137
+ current_role: str = "user",
138
+ session_summary: str | None = None,
139
+ ) -> list[dict[str, Any]]:
140
+ """Build the complete message list for an LLM call."""
141
+ runtime_ctx = self._build_runtime_context(channel, chat_id, self.timezone, session_summary=session_summary)
142
+ user_content = self._build_user_content(current_message, media)
143
+
144
+ # Merge runtime context and user content into a single user message
145
+ # to avoid consecutive same-role messages that some providers reject.
146
+ if isinstance(user_content, str):
147
+ merged = f"{runtime_ctx}\n\n{user_content}"
148
+ else:
149
+ merged = [{"type": "text", "text": runtime_ctx}] + user_content
150
+ messages = [
151
+ {"role": "system", "content": self.build_system_prompt(skill_names, channel=channel)},
152
+ *history,
153
+ ]
154
+ if messages[-1].get("role") == current_role:
155
+ last = dict(messages[-1])
156
+ last["content"] = self._merge_message_content(last.get("content"), merged)
157
+ messages[-1] = last
158
+ return messages
159
+ messages.append({"role": current_role, "content": merged})
160
+ return messages
161
+
162
+ def _build_user_content(self, text: str, media: list[str] | None) -> str | list[dict[str, Any]]:
163
+ """Build user message content with optional base64-encoded images."""
164
+ if not media:
165
+ return text
166
+
167
+ images = []
168
+ for path in media:
169
+ p = Path(path)
170
+ if not p.is_file():
171
+ continue
172
+ raw = p.read_bytes()
173
+ mime = detect_image_mime(raw) or mimetypes.guess_type(path)[0]
174
+ if not mime or not mime.startswith("image/"):
175
+ continue
176
+ b64 = base64.b64encode(raw).decode()
177
+ images.append({
178
+ "type": "image_url",
179
+ "image_url": {"url": f"data:{mime};base64,{b64}"},
180
+ "_meta": {"path": str(p)},
181
+ })
182
+
183
+ if not images:
184
+ return text
185
+ return images + [{"type": "text", "text": text}]
186
+
187
+ def add_tool_result(
188
+ self, messages: list[dict[str, Any]],
189
+ tool_call_id: str, tool_name: str, result: Any,
190
+ ) -> list[dict[str, Any]]:
191
+ """Add a tool result to the message list."""
192
+ messages.append({"role": "tool", "tool_call_id": tool_call_id, "name": tool_name, "content": result})
193
+ return messages
194
+
195
+ def add_assistant_message(
196
+ self, messages: list[dict[str, Any]],
197
+ content: str | None,
198
+ tool_calls: list[dict[str, Any]] | None = None,
199
+ reasoning_content: str | None = None,
200
+ thinking_blocks: list[dict] | None = None,
201
+ ) -> list[dict[str, Any]]:
202
+ """Add an assistant message to the message list."""
203
+ messages.append(build_assistant_message(
204
+ content,
205
+ tool_calls=tool_calls,
206
+ reasoning_content=reasoning_content,
207
+ thinking_blocks=thinking_blocks,
208
+ ))
209
+ return messages
@@ -0,0 +1,103 @@
1
+ """Shared lifecycle hook primitives for agent runs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any
7
+
8
+ from loguru import logger
9
+
10
+ from pythinker.providers.base import LLMResponse, ToolCallRequest
11
+
12
+
13
+ @dataclass(slots=True)
14
+ class AgentHookContext:
15
+ """Mutable per-iteration state exposed to runner hooks."""
16
+
17
+ iteration: int
18
+ messages: list[dict[str, Any]]
19
+ response: LLMResponse | None = None
20
+ usage: dict[str, int] = field(default_factory=dict)
21
+ tool_calls: list[ToolCallRequest] = field(default_factory=list)
22
+ tool_results: list[Any] = field(default_factory=list)
23
+ tool_events: list[dict[str, str]] = field(default_factory=list)
24
+ final_content: str | None = None
25
+ stop_reason: str | None = None
26
+ error: str | None = None
27
+
28
+
29
+ class AgentHook:
30
+ """Minimal lifecycle surface for shared runner customization."""
31
+
32
+ def __init__(self, reraise: bool = False) -> None:
33
+ self._reraise = reraise
34
+
35
+ def wants_streaming(self) -> bool:
36
+ return False
37
+
38
+ async def before_iteration(self, context: AgentHookContext) -> None:
39
+ pass
40
+
41
+ async def on_stream(self, context: AgentHookContext, delta: str) -> None:
42
+ pass
43
+
44
+ async def on_stream_end(self, context: AgentHookContext, *, resuming: bool) -> None:
45
+ pass
46
+
47
+ async def before_execute_tools(self, context: AgentHookContext) -> None:
48
+ pass
49
+
50
+ async def after_iteration(self, context: AgentHookContext) -> None:
51
+ pass
52
+
53
+ def finalize_content(self, context: AgentHookContext, content: str | None) -> str | None:
54
+ return content
55
+
56
+
57
+ class CompositeHook(AgentHook):
58
+ """Fan-out hook that delegates to an ordered list of hooks.
59
+
60
+ Error isolation: async methods catch and log per-hook exceptions
61
+ so a faulty custom hook cannot crash the agent loop.
62
+ ``finalize_content`` is a pipeline (no isolation — bugs should surface).
63
+ """
64
+
65
+ __slots__ = ("_hooks",)
66
+
67
+ def __init__(self, hooks: list[AgentHook]) -> None:
68
+ super().__init__()
69
+ self._hooks = list(hooks)
70
+
71
+ def wants_streaming(self) -> bool:
72
+ return any(h.wants_streaming() for h in self._hooks)
73
+
74
+ async def _for_each_hook_safe(self, method_name: str, *args: Any, **kwargs: Any) -> None:
75
+ for h in self._hooks:
76
+ if getattr(h, "_reraise", False):
77
+ await getattr(h, method_name)(*args, **kwargs)
78
+ continue
79
+
80
+ try:
81
+ await getattr(h, method_name)(*args, **kwargs)
82
+ except Exception:
83
+ logger.exception("AgentHook.{} error in {}", method_name, type(h).__name__)
84
+
85
+ async def before_iteration(self, context: AgentHookContext) -> None:
86
+ await self._for_each_hook_safe("before_iteration", context)
87
+
88
+ async def on_stream(self, context: AgentHookContext, delta: str) -> None:
89
+ await self._for_each_hook_safe("on_stream", context, delta)
90
+
91
+ async def on_stream_end(self, context: AgentHookContext, *, resuming: bool) -> None:
92
+ await self._for_each_hook_safe("on_stream_end", context, resuming=resuming)
93
+
94
+ async def before_execute_tools(self, context: AgentHookContext) -> None:
95
+ await self._for_each_hook_safe("before_execute_tools", context)
96
+
97
+ async def after_iteration(self, context: AgentHookContext) -> None:
98
+ await self._for_each_hook_safe("after_iteration", context)
99
+
100
+ def finalize_content(self, context: AgentHookContext, content: str | None) -> str | None:
101
+ for h in self._hooks:
102
+ content = h.finalize_content(context, content)
103
+ return content