caudate-cli 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 (153) hide show
  1. api/__init__.py +5 -0
  2. api/anthropic_compat.py +1518 -0
  3. api/artifact_viewer.py +366 -0
  4. api/caudate_middleware.py +618 -0
  5. api/forge_bootstrapper_routes.py +377 -0
  6. api/forge_routes.py +630 -0
  7. api/forge_system_routes.py +294 -0
  8. api/openai_compat.py +1993 -0
  9. api/server.py +667 -0
  10. api/storyboard_page.py +677 -0
  11. caudate_cli-0.1.0.dist-info/METADATA +354 -0
  12. caudate_cli-0.1.0.dist-info/RECORD +153 -0
  13. caudate_cli-0.1.0.dist-info/WHEEL +5 -0
  14. caudate_cli-0.1.0.dist-info/entry_points.txt +2 -0
  15. caudate_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  16. caudate_cli-0.1.0.dist-info/top_level.txt +14 -0
  17. cognos_mcp/__init__.py +4 -0
  18. cognos_mcp/bridge.py +41 -0
  19. cognos_mcp/client.py +70 -0
  20. cognos_mcp/config.py +49 -0
  21. cognos_mcp/server.py +66 -0
  22. config.py +82 -0
  23. core/__init__.py +0 -0
  24. core/agent.py +468 -0
  25. core/agentic_loop.py +731 -0
  26. core/anthropic_auth.py +91 -0
  27. core/background.py +113 -0
  28. core/banner.py +134 -0
  29. core/bootstrap.py +292 -0
  30. core/citations.py +131 -0
  31. core/compaction.py +109 -0
  32. core/constitution.py +198 -0
  33. core/diff_viewer.py +87 -0
  34. core/export.py +85 -0
  35. core/file_refs.py +119 -0
  36. core/files.py +199 -0
  37. core/hooks.py +209 -0
  38. core/image.py +599 -0
  39. core/input.py +91 -0
  40. core/loop.py +238 -0
  41. core/memory_md.py +147 -0
  42. core/notifications.py +99 -0
  43. core/ownership.py +181 -0
  44. core/paste.py +81 -0
  45. core/permissions.py +210 -0
  46. core/plan_mode.py +215 -0
  47. core/sandbox_prompt.py +185 -0
  48. core/scheduler.py +195 -0
  49. core/schemas.py +202 -0
  50. core/session.py +90 -0
  51. core/settings.py +132 -0
  52. core/skills.py +398 -0
  53. core/slash_commands.py +977 -0
  54. core/statusline.py +61 -0
  55. core/subagent.py +300 -0
  56. core/thinking.py +50 -0
  57. core/updater.py +122 -0
  58. core/usage.py +109 -0
  59. core/worktree.py +93 -0
  60. execution/__init__.py +0 -0
  61. execution/executor.py +329 -0
  62. execution/plugins.py +108 -0
  63. execution/tools/__init__.py +0 -0
  64. execution/tools/agent_tool.py +107 -0
  65. execution/tools/agentic_tool.py +297 -0
  66. execution/tools/artifact_tool.py +191 -0
  67. execution/tools/ask_user_question_tool.py +137 -0
  68. execution/tools/base.py +81 -0
  69. execution/tools/calculator_tool.py +137 -0
  70. execution/tools/cognos_card_tool.py +124 -0
  71. execution/tools/cron_tool.py +215 -0
  72. execution/tools/datetime_tool.py +215 -0
  73. execution/tools/describe_image_tool.py +161 -0
  74. execution/tools/draw_tool.py +164 -0
  75. execution/tools/edit_image_tool.py +262 -0
  76. execution/tools/edit_tool.py +245 -0
  77. execution/tools/file_tool.py +90 -0
  78. execution/tools/find_anywhere_tool.py +255 -0
  79. execution/tools/forge_feature_tools.py +377 -0
  80. execution/tools/glob_tool.py +59 -0
  81. execution/tools/grep_tool.py +89 -0
  82. execution/tools/http_request_tool.py +224 -0
  83. execution/tools/load_skill_tool.py +104 -0
  84. execution/tools/longcat_avatar_tool.py +384 -0
  85. execution/tools/mcp_tool.py +100 -0
  86. execution/tools/notebook_tool.py +279 -0
  87. execution/tools/openapi_tool.py +440 -0
  88. execution/tools/plan_mode_tool.py +95 -0
  89. execution/tools/push_notification_tool.py +157 -0
  90. execution/tools/python_tool.py +61 -0
  91. execution/tools/respond_tool.py +40 -0
  92. execution/tools/sandbox_tool.py +378 -0
  93. execution/tools/search_tool.py +153 -0
  94. execution/tools/semantic_search_tool.py +106 -0
  95. execution/tools/shell_tool.py +283 -0
  96. execution/tools/speak_tool.py +134 -0
  97. execution/tools/storyboard_tool.py +727 -0
  98. execution/tools/system_info_tool.py +212 -0
  99. execution/tools/task_tool.py +323 -0
  100. execution/tools/think_tool.py +49 -0
  101. execution/tools/transcribe_audio_tool.py +86 -0
  102. execution/tools/update_memory_tool.py +92 -0
  103. execution/tools/web_fetch_tool.py +82 -0
  104. execution/tools/worktree_tool.py +174 -0
  105. llm/__init__.py +0 -0
  106. llm/fallback.py +116 -0
  107. llm/models.py +320 -0
  108. llm/provider.py +1356 -0
  109. llm/router.py +373 -0
  110. main.py +1889 -0
  111. memory/__init__.py +0 -0
  112. memory/episodic.py +99 -0
  113. memory/procedural.py +145 -0
  114. memory/semantic.py +71 -0
  115. memory/working.py +64 -0
  116. nn/__init__.py +43 -0
  117. nn/auto_evolve.py +245 -0
  118. nn/caudate.py +136 -0
  119. nn/config.py +141 -0
  120. nn/consolidator.py +81 -0
  121. nn/data.py +1635 -0
  122. nn/encoder.py +258 -0
  123. nn/forge_advisor.py +303 -0
  124. nn/format.py +235 -0
  125. nn/heads.py +432 -0
  126. nn/observer.py +994 -0
  127. nn/policy.py +214 -0
  128. nn/runtime.py +343 -0
  129. nn/scorer.py +175 -0
  130. nn/trainer.py +515 -0
  131. nn/vision.py +352 -0
  132. personality/__init__.py +23 -0
  133. personality/engine.py +129 -0
  134. personality/identity.py +144 -0
  135. personality/inner_voice.py +100 -0
  136. personality/mood.py +205 -0
  137. planning/__init__.py +0 -0
  138. planning/dev_server.py +221 -0
  139. planning/forge_models.py +718 -0
  140. planning/orchestrator.py +1363 -0
  141. planning/planner.py +451 -0
  142. planning/task_graph.py +61 -0
  143. reflection/__init__.py +0 -0
  144. reflection/meta_learner.py +156 -0
  145. reflection/reflector.py +127 -0
  146. ui/__init__.py +5 -0
  147. ui/display.py +88 -0
  148. voice/__init__.py +0 -0
  149. voice/conversation.py +125 -0
  150. voice/listener.py +111 -0
  151. voice/speaker.py +59 -0
  152. voice/stt.py +126 -0
  153. voice/tts.py +214 -0
core/agentic_loop.py ADDED
@@ -0,0 +1,731 @@
1
+ """Agentic Loop — real-time tool-calling cognitive loop.
2
+
3
+ The LLM decides which tools to call as it reasons, rather than pre-planning
4
+ a DAG. This is the Claude SDK style of agent loop.
5
+
6
+ Flow:
7
+ 1. Append user message to history
8
+ 2. Call LLM with tools + history
9
+ 3. If response contains tool_calls: execute each, append tool_results, loop
10
+ 4. If response contains text only: return final text
11
+
12
+ Memory systems (episodic/semantic) are consulted once at the start of a turn
13
+ and injected as context into the system prompt.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ from typing import Any, AsyncIterator, Callable
20
+
21
+ from core.citations import CitationBlock, Document, parse_citations, render_documents_block
22
+ from core.compaction import ContextCompactor
23
+ from core.files import KIND_IMAGE, FileStore
24
+ from core.hooks import HookEvent, HookManager
25
+ from core.permissions import PermissionManager
26
+ from core.schemas import (
27
+ Episode,
28
+ StreamEvent,
29
+ ThinkingBlock,
30
+ ToolResultBlock,
31
+ ToolResultStatus,
32
+ ToolUseBlock,
33
+ )
34
+ from core.thinking import ThinkingManager
35
+ from execution.executor import Executor
36
+ from llm.provider import LLMProvider
37
+
38
+ logger = logging.getLogger(__name__)
39
+
40
+ MAX_ITERATIONS = 25
41
+ MAX_CONSECUTIVE_ERRORS = 3
42
+
43
+ DEFAULT_SYSTEM_PROMPT = """You are Cognos — a local-first cognitive assistant.
44
+
45
+ You have access to tools for reading/writing files, running shell commands,
46
+ executing Python, searching the web, and thinking. Use them proactively to
47
+ accomplish the user's goal. When you have enough information, respond with a
48
+ final answer as plain text (no tool call).
49
+
50
+ Be direct and concise. Do not narrate what you are about to do — just do it.
51
+ Prefer the most specific tool for each subtask (e.g. Read over Bash cat).
52
+ """
53
+
54
+
55
+ class AgenticLoop:
56
+ """Real-time tool-calling agent loop."""
57
+
58
+ def __init__(
59
+ self,
60
+ llm: LLMProvider,
61
+ executor: Executor,
62
+ episodic=None,
63
+ semantic=None,
64
+ working=None,
65
+ system_prompt: str = DEFAULT_SYSTEM_PROMPT,
66
+ max_iterations: int = MAX_ITERATIONS,
67
+ thinking: bool = False,
68
+ on_thinking=None,
69
+ permissions: PermissionManager | None = None,
70
+ hooks: HookManager | None = None,
71
+ compactor: ContextCompactor | None = None,
72
+ session_id: str | None = None,
73
+ system_prompt_provider: "Callable[[str], str] | None" = None,
74
+ thinking_instruction_wrapper: "Callable[[str], str] | None" = None,
75
+ file_store: FileStore | None = None,
76
+ ):
77
+ self.llm = llm
78
+ self.executor = executor
79
+ self.episodic = episodic
80
+ self.semantic = semantic
81
+ self.working = working
82
+ self.system_prompt = system_prompt
83
+ self.max_iterations = max_iterations
84
+ self.thinking_manager = ThinkingManager(enabled=thinking)
85
+ self.on_thinking = on_thinking # optional callback(list[ThinkingBlock])
86
+ self.permissions = permissions
87
+ self.hooks = hooks or HookManager()
88
+ self.compactor = compactor
89
+ self.session_id = session_id
90
+ self.system_prompt_provider = system_prompt_provider
91
+ self.thinking_instruction_wrapper = thinking_instruction_wrapper
92
+ self.file_store = file_store
93
+ self.documents: list[Document] = []
94
+ self.last_citations: list[CitationBlock] = []
95
+ self.messages: list[dict[str, Any]] = []
96
+ self._episodes: list[Episode] = []
97
+ self._thinking_log: list[ThinkingBlock] = []
98
+ # Caudate observer — set by CognosAgent if the NN subsystem is
99
+ # available. Stays None for code paths that build AgenticLoop
100
+ # directly (subagents, tests).
101
+ self.caudate = None
102
+
103
+ @property
104
+ def thinking(self) -> bool:
105
+ return self.thinking_manager.enabled
106
+
107
+ @thinking.setter
108
+ def thinking(self, value: bool) -> None:
109
+ self.thinking_manager.enabled = value
110
+
111
+ # ------------------------------------------------------------------
112
+ # Public entry points
113
+ # ------------------------------------------------------------------
114
+
115
+ async def run(
116
+ self,
117
+ user_message: str,
118
+ attachments: list[str] | None = None,
119
+ documents: list[Document] | None = None,
120
+ ) -> str:
121
+ """Run the loop to completion and return the final text response.
122
+
123
+ `attachments` is a list of file_ids previously registered with
124
+ the FileStore; they are materialized into the user message.
125
+
126
+ `documents` is a list of Document objects the model may cite from;
127
+ they are injected into the system prompt and their citation markers
128
+ are parsed out of the response (see self.last_citations).
129
+ """
130
+ self.documents = documents or []
131
+ self.last_citations = []
132
+ await self.hooks.emit(HookEvent.USER_PROMPT_SUBMIT, {"message": user_message})
133
+ self._start_turn(user_message, attachments=attachments or [])
134
+ consecutive_errors = 0
135
+ # Notify Caudate *first* so her top-K is populated before we
136
+ # build the tool list (ADR-0007 constrained routing reads
137
+ # _last_prediction).
138
+ self._notify_caudate_turn_start(user_message)
139
+ tools = self._caudate_constrained_tools(self.executor.tool_definitions())
140
+
141
+ for iteration in range(self.max_iterations):
142
+ await self._maybe_compact()
143
+ self._refresh_system_prompt()
144
+ try:
145
+ response = await self.llm.chat(self.messages, tools=tools)
146
+ consecutive_errors = 0
147
+ except Exception as e:
148
+ consecutive_errors += 1
149
+ logger.error(
150
+ f"LLM error at iteration {iteration} "
151
+ f"(consecutive={consecutive_errors}): {e}"
152
+ )
153
+ if consecutive_errors >= MAX_CONSECUTIVE_ERRORS:
154
+ msg = f"(giving up after {consecutive_errors} consecutive LLM errors: {e})"
155
+ await self.hooks.emit(HookEvent.STOP, {"text": msg, "error": str(e)})
156
+ return msg
157
+ continue
158
+
159
+ # Extract thinking blocks from text
160
+ content, _ = self._extract_thinking(response.content)
161
+
162
+ # Append assistant message (use the stripped content)
163
+ self._append_assistant_message(content, response.tool_calls)
164
+
165
+ if not response.tool_calls:
166
+ if self.documents:
167
+ content, self.last_citations = parse_citations(
168
+ content, self.documents,
169
+ )
170
+ await self.hooks.emit(HookEvent.STOP, {"text": content})
171
+ self._notify_caudate_turn_end()
172
+ return content
173
+
174
+ # Execute tool calls
175
+ for call in response.tool_calls:
176
+ result_block = await self._execute_call(call)
177
+ self._append_tool_result(result_block)
178
+ self._notify_caudate_tool_use(call.name, was_error=result_block.is_error)
179
+
180
+ logger.warning(f"Agentic loop hit max_iterations={self.max_iterations}")
181
+ self._notify_caudate_turn_end(reward=0.2) # hit cap → low reward
182
+ return "(max iterations reached)"
183
+
184
+ async def run_streaming(self, user_message: str) -> AsyncIterator[StreamEvent]:
185
+ """Run the loop streaming tokens/events as they arrive."""
186
+ self._start_turn(user_message)
187
+ self._notify_caudate_turn_start(user_message)
188
+ tools = self._caudate_constrained_tools(self.executor.tool_definitions())
189
+
190
+ for iteration in range(self.max_iterations):
191
+ await self._maybe_compact()
192
+ self._refresh_system_prompt()
193
+
194
+ collected_text = ""
195
+ collected_calls: list[ToolUseBlock] = []
196
+ stop_reason: str | None = None
197
+
198
+ async for event in self.llm.stream(self.messages, tools=tools):
199
+ if event.type == "text_delta" and event.delta:
200
+ collected_text += event.delta
201
+ elif event.type == "tool_use_end":
202
+ collected_calls.append(ToolUseBlock(
203
+ id=event.tool_use_id or "",
204
+ name=event.tool_name or "",
205
+ input=event.tool_input or {},
206
+ ))
207
+ elif event.type == "message_stop":
208
+ stop_reason = event.stop_reason
209
+ yield event
210
+
211
+ # Strip thinking blocks before persisting to history
212
+ collected_text, _ = self._extract_thinking(collected_text)
213
+
214
+ # Append assistant message
215
+ self._append_assistant_message(collected_text, collected_calls)
216
+
217
+ if not collected_calls:
218
+ self._notify_caudate_turn_end()
219
+ return
220
+
221
+ for call in collected_calls:
222
+ result_block = await self._execute_call(call)
223
+ self._append_tool_result(result_block)
224
+ self._notify_caudate_tool_use(call.name, was_error=result_block.is_error)
225
+ yield StreamEvent(
226
+ type="tool_result",
227
+ tool_use_id=call.id,
228
+ delta=str(result_block.content)[:500],
229
+ )
230
+
231
+ logger.warning(f"Agentic loop (stream) hit max_iterations={self.max_iterations}")
232
+
233
+ def reset(self) -> None:
234
+ """Clear conversation history (new session)."""
235
+ self.messages = []
236
+ self._episodes = []
237
+
238
+ async def _maybe_compact(self) -> None:
239
+ """Run context compaction if enabled and threshold reached."""
240
+ if self.compactor is None:
241
+ return
242
+ if not self.compactor.should_compact(self.messages):
243
+ return
244
+ await self.hooks.emit(HookEvent.PRE_COMPACT, {"count": len(self.messages)})
245
+ self.messages = await self.compactor.compact(self.messages)
246
+
247
+ def load_messages(self, messages: list[dict[str, Any]]) -> None:
248
+ """Restore a conversation (e.g. from a saved session)."""
249
+ self.messages = list(messages)
250
+
251
+ # ------------------------------------------------------------------
252
+ # Internals
253
+ # ------------------------------------------------------------------
254
+
255
+ def _start_turn(
256
+ self,
257
+ user_message: str,
258
+ attachments: list[str] | None = None,
259
+ ) -> None:
260
+ """Ensure system prompt is present and append the user message."""
261
+ if not self.messages:
262
+ self.messages.append({
263
+ "role": "system",
264
+ "content": self._build_system_prompt(user_message),
265
+ })
266
+
267
+ user_payload = self._build_user_message(user_message, attachments or [])
268
+ self.messages.append({"role": "user", "content": user_payload})
269
+
270
+ def _build_user_message(
271
+ self,
272
+ text: str,
273
+ attachments: list[str],
274
+ ) -> str | list[dict[str, Any]]:
275
+ """Render the user turn, materializing attachments.
276
+
277
+ Returns a plain string if no attachments (or the store is missing),
278
+ otherwise a multimodal content-block list with:
279
+ - extracted text for PDFs / text files (inlined as a text block)
280
+ - image_url blocks for images
281
+ - a final text block with the user's actual message
282
+ """
283
+ if not attachments or self.file_store is None:
284
+ return text
285
+
286
+ blocks: list[dict[str, Any]] = []
287
+ for file_id in attachments:
288
+ rec = self.file_store.get(file_id)
289
+ if rec is None:
290
+ blocks.append({
291
+ "type": "text",
292
+ "text": f"[attachment {file_id} not found]",
293
+ })
294
+ continue
295
+
296
+ if rec.kind == KIND_IMAGE:
297
+ content = self.file_store.to_image_content(file_id)
298
+ if content is not None:
299
+ blocks.append({
300
+ "type": "text",
301
+ "text": f"[image: {rec.filename}]",
302
+ })
303
+ blocks.append(content)
304
+ continue
305
+
306
+ extracted = self.file_store.extract_text(file_id)
307
+ if extracted:
308
+ blocks.append({
309
+ "type": "text",
310
+ "text": f"--- file: {rec.filename} ({rec.kind}) ---\n{extracted}",
311
+ })
312
+ else:
313
+ blocks.append({
314
+ "type": "text",
315
+ "text": f"[binary file: {rec.filename} at {rec.path}]",
316
+ })
317
+
318
+ blocks.append({"type": "text", "text": text})
319
+ return blocks
320
+
321
+ def _build_system_prompt(self, user_message: str) -> str:
322
+ """Compose the full system prompt — base + personality + memory + docs + thinking."""
323
+ base = self.system_prompt
324
+ if self.system_prompt_provider is not None:
325
+ try:
326
+ base = self.system_prompt_provider(base)
327
+ except Exception as e:
328
+ logger.debug(f"system_prompt_provider failed: {e}")
329
+
330
+ context = self._memory_context(user_message)
331
+ if context:
332
+ base = f"{base}\n\n# Relevant context\n{context}"
333
+
334
+ if self.documents:
335
+ base = f"{base}\n\n{render_documents_block(self.documents)}"
336
+
337
+ base = self.thinking_manager.augment_system_prompt(base)
338
+ if self.thinking_manager.enabled and self.thinking_instruction_wrapper is not None:
339
+ try:
340
+ base = self.thinking_instruction_wrapper(base)
341
+ except Exception as e:
342
+ logger.debug(f"thinking_instruction_wrapper failed: {e}")
343
+
344
+ # Caudate hint injection — only at WHISPER level or above.
345
+ # Earlier levels stay silent so untrained predictions don't
346
+ # poison the LLM's prompt.
347
+ hint = self._caudate_prompt_hint()
348
+ if hint:
349
+ base = f"{base}\n\n{hint}"
350
+ return base
351
+
352
+ def _caudate_prompt_hint(self) -> str:
353
+ """Return a hint block to splice into the system prompt, or empty."""
354
+ if self.caudate is None or not self.caudate.can_whisper():
355
+ return ""
356
+ pred = self.caudate._last_prediction
357
+ if pred is None:
358
+ return ""
359
+ # Don't whisper unless reasonably confident.
360
+ if pred.tool_confidence < self.caudate.cfg.advisor_min_confidence:
361
+ return ""
362
+ level = self.caudate.policy.level.label
363
+ # Tone scales with trust. Whisper = soft hint. Advisor = stronger.
364
+ if self.caudate.can_control():
365
+ preface = (
366
+ "## Caudate (your trained action-selection net) recommends:"
367
+ )
368
+ elif self.caudate.can_advise():
369
+ preface = (
370
+ "## Caudate (advisor) suggests, based on prior sessions:"
371
+ )
372
+ else:
373
+ preface = (
374
+ "## Caudate (whispering — still learning) thinks you might want:"
375
+ )
376
+ return (
377
+ f"{preface}\n"
378
+ f" next tool: {pred.tool} (confidence {pred.tool_confidence:.2f})\n"
379
+ f" routing: {pred.tier} (confidence {pred.tier_confidence:.2f})\n"
380
+ f" thinking: {'helpful' if pred.think >= 0.5 else 'not needed'} "
381
+ f"(p={pred.think:.2f})\n"
382
+ f" expected reward: {pred.value:.2f}\n"
383
+ f"You are free to disagree — Caudate ({level}) has not seen "
384
+ f"this exact context, only patterns from past turns."
385
+ )
386
+
387
+ def _refresh_system_prompt(self) -> None:
388
+ """Regenerate the system prompt so dynamic state (mood) stays current."""
389
+ if self.system_prompt_provider is None:
390
+ return
391
+ if not self.messages or self.messages[0].get("role") != "system":
392
+ return
393
+ last_user = next(
394
+ (m.get("content", "") for m in reversed(self.messages) if m.get("role") == "user"),
395
+ "",
396
+ )
397
+ self.messages[0] = {
398
+ "role": "system",
399
+ "content": self._build_system_prompt(last_user),
400
+ }
401
+
402
+ def _extract_thinking(self, text: str) -> tuple[str, list[ThinkingBlock]]:
403
+ """Strip <thinking>…</thinking> blocks out of text.
404
+
405
+ Logs them to self._thinking_log and fires the on_thinking callback.
406
+ Returns (stripped_text, blocks).
407
+ """
408
+ if not self.thinking_manager.enabled or not text:
409
+ return text, []
410
+ blocks, stripped = self.thinking_manager.parse(text)
411
+ if blocks:
412
+ self._thinking_log.extend(blocks)
413
+ if self.on_thinking:
414
+ try:
415
+ self.on_thinking(blocks)
416
+ except Exception as e:
417
+ logger.debug(f"on_thinking callback failed: {e}")
418
+ return stripped, blocks
419
+
420
+ @property
421
+ def thinking_log(self) -> list[ThinkingBlock]:
422
+ return list(self._thinking_log)
423
+
424
+ def _memory_context(self, query: str) -> str:
425
+ """Pull relevant episodes/knowledge into the system prompt."""
426
+ parts: list[str] = []
427
+ if self.episodic:
428
+ try:
429
+ episodes = self.episodic.recall(query)
430
+ if episodes:
431
+ parts.append("Past episodes:")
432
+ for ep in episodes[:3]:
433
+ outcome = (
434
+ ep.result.status.value
435
+ if ep.result else "unknown"
436
+ )
437
+ parts.append(f" - {ep.action} -> {outcome}")
438
+ except Exception as e:
439
+ logger.debug(f"Episodic recall failed: {e}")
440
+
441
+ if self.semantic:
442
+ try:
443
+ knowledge = self.semantic.recall(query)
444
+ if knowledge:
445
+ parts.append("Known facts:")
446
+ for k in knowledge[:3]:
447
+ parts.append(f" - {k.get('content', '')}")
448
+ except Exception as e:
449
+ logger.debug(f"Semantic recall failed: {e}")
450
+ return "\n".join(parts)
451
+
452
+ def _append_assistant_message(
453
+ self,
454
+ content: str,
455
+ tool_calls: list[ToolUseBlock],
456
+ ) -> None:
457
+ """Append an assistant message (text + optional tool_calls) to history."""
458
+ msg: dict[str, Any] = {"role": "assistant", "content": content or ""}
459
+ if tool_calls:
460
+ msg["tool_calls"] = [
461
+ {
462
+ "id": tc.id,
463
+ "type": "function",
464
+ "function": {
465
+ "name": tc.name,
466
+ "arguments": _json_dump(tc.input),
467
+ },
468
+ }
469
+ for tc in tool_calls
470
+ ]
471
+ self.messages.append(msg)
472
+
473
+ def _append_tool_result(self, block: ToolResultBlock) -> None:
474
+ """Append a tool_result message in LiteLLM/OpenAI format."""
475
+ self.messages.append({
476
+ "role": "tool",
477
+ "tool_call_id": block.tool_use_id,
478
+ "content": str(block.content),
479
+ })
480
+
481
+ async def _execute_call(self, call: ToolUseBlock) -> ToolResultBlock:
482
+ """Execute one tool call and return a ToolResultBlock."""
483
+ logger.info(f"Tool call: {call.name}({call.input})")
484
+
485
+ # PRE_TOOL_USE hook — can block or mutate args
486
+ pre_response = await self.hooks.emit(
487
+ HookEvent.PRE_TOOL_USE,
488
+ {"tool": call.name, "tool_name": call.name, "args": call.input},
489
+ )
490
+ if pre_response.get("block"):
491
+ reason = pre_response.get("reason", "blocked by hook")
492
+ return ToolResultBlock(
493
+ tool_use_id=call.id,
494
+ content=f"Blocked by pre_tool_use hook: {reason}",
495
+ is_error=True,
496
+ )
497
+ if "modified_args" in pre_response:
498
+ call.input = pre_response["modified_args"]
499
+
500
+ if self.permissions is not None:
501
+ allowed, reason = await self.permissions.check(call.name, call.input)
502
+ if not allowed:
503
+ return ToolResultBlock(
504
+ tool_use_id=call.id,
505
+ content=f"Permission denied ({reason})",
506
+ is_error=True,
507
+ )
508
+
509
+ result = await self.executor.execute_tool(call.name, call.input)
510
+
511
+ hook_event = (
512
+ HookEvent.POST_TOOL_USE
513
+ if result.status == ToolResultStatus.SUCCESS
514
+ else HookEvent.POST_TOOL_USE_FAILURE
515
+ )
516
+ await self.hooks.emit(hook_event, {
517
+ "tool": call.name,
518
+ "tool_name": call.name,
519
+ "args": call.input,
520
+ "status": result.status.value,
521
+ "output": str(result.output)[:500] if result.output else None,
522
+ "error": result.error,
523
+ })
524
+
525
+ is_error = result.status != ToolResultStatus.SUCCESS
526
+ content = result.error if is_error else result.output
527
+
528
+ if self.working:
529
+ try:
530
+ self.working.store(f"tool_{call.name}_last", content)
531
+ except Exception:
532
+ pass
533
+
534
+ return ToolResultBlock(
535
+ tool_use_id=call.id,
536
+ content=content,
537
+ is_error=is_error,
538
+ )
539
+
540
+ # ------------------------------------------------------------------
541
+ # Caudate observer hooks — silent no-ops if caudate is None.
542
+ # The observer is the bridge between this loop and the NN subsystem;
543
+ # see nn/observer.py.
544
+ # ------------------------------------------------------------------
545
+
546
+ def _notify_caudate_turn_start(self, user_message: str) -> None:
547
+ if self.caudate is None:
548
+ return
549
+ try:
550
+ recent = []
551
+ for m in self.messages[-self.caudate.cfg.msg_window:]:
552
+ role = m.get("role", "?")
553
+ c = m.get("content")
554
+ if isinstance(c, list):
555
+ c = " ".join(b.get("text", "") for b in c if isinstance(b, dict))
556
+ if c:
557
+ recent.append(f"{role}: {str(c)[:400]}")
558
+
559
+ # Vision channel: scan recent messages for [file:<id>] markers
560
+ # and resolve any that are images via the FileStore.
561
+ image_paths = self._collect_recent_image_paths()
562
+
563
+ # Hand the executor's current tool registry to Caudate so
564
+ # the contrastive tool head has real candidates to score.
565
+ # Without this the head only sees the synthetic <no_tool>
566
+ # slot and the trained discrimination weights stay dormant.
567
+ available_tools = self._caudate_available_tools()
568
+
569
+ pred = self.caudate.on_turn_start(
570
+ recent_messages=recent,
571
+ image_paths=image_paths,
572
+ available_tools=available_tools,
573
+ )
574
+ # CONTROLLER level: Caudate gates thinking. If she predicts
575
+ # thinking won't help with high confidence, save the tokens.
576
+ if pred is not None and self.caudate.can_control():
577
+ if pred.think < 0.3 and self.thinking:
578
+ self.thinking = False
579
+ logger.info("Caudate disabled thinking for this turn (controller)")
580
+ elif pred.think > 0.7 and not self.thinking:
581
+ self.thinking = True
582
+ logger.info("Caudate enabled thinking for this turn (controller)")
583
+ except Exception as e:
584
+ logger.debug(f"caudate.on_turn_start failed: {e}")
585
+
586
+ def _collect_recent_image_paths(self) -> list[str]:
587
+ """Pull image-kind file paths off recent messages.
588
+
589
+ Two sources:
590
+ 1. Multimodal user content blocks of type 'image_url' — these
591
+ carry data: URLs we can't pass to CLIP, so we skip them.
592
+ 2. `[file:<uuid>]` markers in either user or assistant text.
593
+ We resolve each id via the FileStore and keep only ones
594
+ whose kind is 'image'.
595
+ """
596
+ if self.file_store is None:
597
+ return []
598
+ import re as _re
599
+ paths: list[str] = []
600
+ seen: set[str] = set()
601
+ marker_re = _re.compile(r"\[file:([a-f0-9-]+)(?:\s+[^\]]+)?\]")
602
+ for m in self.messages[-self.caudate.cfg.msg_window:] if self.caudate else []:
603
+ content = m.get("content")
604
+ if isinstance(content, list):
605
+ content = " ".join(
606
+ b.get("text", "") for b in content if isinstance(b, dict)
607
+ )
608
+ if not isinstance(content, str):
609
+ continue
610
+ for fid in marker_re.findall(content):
611
+ if fid in seen:
612
+ continue
613
+ seen.add(fid)
614
+ rec = self.file_store.get(fid)
615
+ if rec is not None and rec.kind == "image":
616
+ paths.append(rec.path)
617
+ # Cap to image_window so the encoder doesn't pad uselessly.
618
+ if self.caudate is not None:
619
+ paths = paths[-self.caudate.cfg.image_window:]
620
+ return paths
621
+
622
+ def _caudate_constrained_tools(self, all_tools: list) -> list:
623
+ """ADR-0007: prune the LLM's tool list to Caudate's top-K when she's
624
+ graduated to ADVISOR+ and is confident about a real tool.
625
+
626
+ No-ops (returns ``all_tools`` unchanged) when:
627
+ - Caudate is absent or below ADVISOR
628
+ - No prediction this turn
629
+ - Predicted tool is ``<no_tool>`` or ``<unk>`` (soft-suggest only)
630
+ - Confidence is below ``advisor_min_confidence``
631
+ - Top-K is empty (e.g. checkpoint pre-dates top-K capture)
632
+ - After filtering, fewer than ``advisor_top_k_floor`` tools survive
633
+ (pathological — bail rather than cripple the LLM)
634
+ """
635
+ if self.caudate is None or not self.caudate.can_advise():
636
+ return all_tools
637
+ pred = self.caudate._last_prediction
638
+ if pred is None:
639
+ return all_tools
640
+ if pred.tool in ("<no_tool>", "<unk>"):
641
+ return all_tools
642
+ if pred.tool_confidence < self.caudate.cfg.advisor_min_confidence:
643
+ return all_tools
644
+ if not pred.tool_top_k:
645
+ return all_tools
646
+
647
+ cfg = self.caudate.cfg
648
+ k = max(cfg.advisor_top_k, cfg.advisor_top_k_floor)
649
+ keep_names: set[str] = {
650
+ name for name, _ in pred.tool_top_k[:k]
651
+ if name not in ("<no_tool>", "<unk>")
652
+ }
653
+ keep_names.add(pred.tool) # predicted tool is always visible
654
+
655
+ def _tool_name(t: Any) -> str:
656
+ if isinstance(t, dict):
657
+ fn = t.get("function") or {}
658
+ return fn.get("name") or t.get("name") or ""
659
+ return getattr(t, "name", "") or ""
660
+
661
+ filtered = [t for t in all_tools if _tool_name(t) in keep_names]
662
+ if len(filtered) < cfg.advisor_top_k_floor:
663
+ logger.debug(
664
+ "Caudate constrained-routing bail: only %d/%d tools survived",
665
+ len(filtered), len(all_tools),
666
+ )
667
+ return all_tools
668
+ logger.info(
669
+ "Caudate pruned tools %d -> %d (pred=%s conf=%.2f)",
670
+ len(all_tools), len(filtered), pred.tool, pred.tool_confidence,
671
+ )
672
+ return filtered
673
+
674
+ def _caudate_available_tools(self) -> list:
675
+ """Build the candidate-tool list Caudate scores against.
676
+
677
+ Each entry is a ``nn.format.ToolDef`` carrying the tool's name
678
+ plus its human-readable description. The description is what the
679
+ contrastive head's text embedder ingests, so a vague description
680
+ hurts discrimination. Importing ToolDef lazily so an import
681
+ failure on the nn stack doesn't break the agent path.
682
+ """
683
+ try:
684
+ from nn.format import ToolDef
685
+ except Exception:
686
+ return []
687
+ out: list = []
688
+ for tool in self.executor._tools.values():
689
+ try:
690
+ out.append(ToolDef(
691
+ name=tool.name,
692
+ description=tool.description or "",
693
+ ))
694
+ except Exception:
695
+ continue
696
+ return out
697
+
698
+ def _notify_caudate_tool_use(self, tool_name: str, was_error: bool) -> None:
699
+ if self.caudate is None:
700
+ return
701
+ try:
702
+ self.caudate.on_tool_use(tool_name, thinking_used=self.thinking)
703
+ except Exception as e:
704
+ logger.debug(f"caudate.on_tool_use failed: {e}")
705
+ # Per-tool reward signal: a failure now reduces this turn's
706
+ # observed reward when the turn ends.
707
+ if was_error and self.caudate is not None and self.caudate._pending is not None:
708
+ self.caudate._pending.tier_used = self.caudate._pending.tier_used # noop
709
+ # mark a transient failure so on_turn_end can read it
710
+ setattr(self.caudate._pending, "_had_error", True)
711
+
712
+ def _notify_caudate_turn_end(self, reward: float | None = None) -> None:
713
+ if self.caudate is None:
714
+ return
715
+ try:
716
+ if reward is None and self.caudate._pending is not None:
717
+ # Heuristic if no explicit reward: 0.7 default,
718
+ # 0.3 if any tool errored mid-turn.
719
+ had_err = getattr(self.caudate._pending, "_had_error", False)
720
+ reward = 0.3 if had_err else 0.7
721
+ self.caudate.on_turn_end(reward=reward)
722
+ except Exception as e:
723
+ logger.debug(f"caudate.on_turn_end failed: {e}")
724
+
725
+
726
+ def _json_dump(obj: Any) -> str:
727
+ import json
728
+ try:
729
+ return json.dumps(obj)
730
+ except (TypeError, ValueError):
731
+ return json.dumps(str(obj))