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.
- api/__init__.py +5 -0
- api/anthropic_compat.py +1518 -0
- api/artifact_viewer.py +366 -0
- api/caudate_middleware.py +618 -0
- api/forge_bootstrapper_routes.py +377 -0
- api/forge_routes.py +630 -0
- api/forge_system_routes.py +294 -0
- api/openai_compat.py +1993 -0
- api/server.py +667 -0
- api/storyboard_page.py +677 -0
- caudate_cli-0.1.0.dist-info/METADATA +354 -0
- caudate_cli-0.1.0.dist-info/RECORD +153 -0
- caudate_cli-0.1.0.dist-info/WHEEL +5 -0
- caudate_cli-0.1.0.dist-info/entry_points.txt +2 -0
- caudate_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- caudate_cli-0.1.0.dist-info/top_level.txt +14 -0
- cognos_mcp/__init__.py +4 -0
- cognos_mcp/bridge.py +41 -0
- cognos_mcp/client.py +70 -0
- cognos_mcp/config.py +49 -0
- cognos_mcp/server.py +66 -0
- config.py +82 -0
- core/__init__.py +0 -0
- core/agent.py +468 -0
- core/agentic_loop.py +731 -0
- core/anthropic_auth.py +91 -0
- core/background.py +113 -0
- core/banner.py +134 -0
- core/bootstrap.py +292 -0
- core/citations.py +131 -0
- core/compaction.py +109 -0
- core/constitution.py +198 -0
- core/diff_viewer.py +87 -0
- core/export.py +85 -0
- core/file_refs.py +119 -0
- core/files.py +199 -0
- core/hooks.py +209 -0
- core/image.py +599 -0
- core/input.py +91 -0
- core/loop.py +238 -0
- core/memory_md.py +147 -0
- core/notifications.py +99 -0
- core/ownership.py +181 -0
- core/paste.py +81 -0
- core/permissions.py +210 -0
- core/plan_mode.py +215 -0
- core/sandbox_prompt.py +185 -0
- core/scheduler.py +195 -0
- core/schemas.py +202 -0
- core/session.py +90 -0
- core/settings.py +132 -0
- core/skills.py +398 -0
- core/slash_commands.py +977 -0
- core/statusline.py +61 -0
- core/subagent.py +300 -0
- core/thinking.py +50 -0
- core/updater.py +122 -0
- core/usage.py +109 -0
- core/worktree.py +93 -0
- execution/__init__.py +0 -0
- execution/executor.py +329 -0
- execution/plugins.py +108 -0
- execution/tools/__init__.py +0 -0
- execution/tools/agent_tool.py +107 -0
- execution/tools/agentic_tool.py +297 -0
- execution/tools/artifact_tool.py +191 -0
- execution/tools/ask_user_question_tool.py +137 -0
- execution/tools/base.py +81 -0
- execution/tools/calculator_tool.py +137 -0
- execution/tools/cognos_card_tool.py +124 -0
- execution/tools/cron_tool.py +215 -0
- execution/tools/datetime_tool.py +215 -0
- execution/tools/describe_image_tool.py +161 -0
- execution/tools/draw_tool.py +164 -0
- execution/tools/edit_image_tool.py +262 -0
- execution/tools/edit_tool.py +245 -0
- execution/tools/file_tool.py +90 -0
- execution/tools/find_anywhere_tool.py +255 -0
- execution/tools/forge_feature_tools.py +377 -0
- execution/tools/glob_tool.py +59 -0
- execution/tools/grep_tool.py +89 -0
- execution/tools/http_request_tool.py +224 -0
- execution/tools/load_skill_tool.py +104 -0
- execution/tools/longcat_avatar_tool.py +384 -0
- execution/tools/mcp_tool.py +100 -0
- execution/tools/notebook_tool.py +279 -0
- execution/tools/openapi_tool.py +440 -0
- execution/tools/plan_mode_tool.py +95 -0
- execution/tools/push_notification_tool.py +157 -0
- execution/tools/python_tool.py +61 -0
- execution/tools/respond_tool.py +40 -0
- execution/tools/sandbox_tool.py +378 -0
- execution/tools/search_tool.py +153 -0
- execution/tools/semantic_search_tool.py +106 -0
- execution/tools/shell_tool.py +283 -0
- execution/tools/speak_tool.py +134 -0
- execution/tools/storyboard_tool.py +727 -0
- execution/tools/system_info_tool.py +212 -0
- execution/tools/task_tool.py +323 -0
- execution/tools/think_tool.py +49 -0
- execution/tools/transcribe_audio_tool.py +86 -0
- execution/tools/update_memory_tool.py +92 -0
- execution/tools/web_fetch_tool.py +82 -0
- execution/tools/worktree_tool.py +174 -0
- llm/__init__.py +0 -0
- llm/fallback.py +116 -0
- llm/models.py +320 -0
- llm/provider.py +1356 -0
- llm/router.py +373 -0
- main.py +1889 -0
- memory/__init__.py +0 -0
- memory/episodic.py +99 -0
- memory/procedural.py +145 -0
- memory/semantic.py +71 -0
- memory/working.py +64 -0
- nn/__init__.py +43 -0
- nn/auto_evolve.py +245 -0
- nn/caudate.py +136 -0
- nn/config.py +141 -0
- nn/consolidator.py +81 -0
- nn/data.py +1635 -0
- nn/encoder.py +258 -0
- nn/forge_advisor.py +303 -0
- nn/format.py +235 -0
- nn/heads.py +432 -0
- nn/observer.py +994 -0
- nn/policy.py +214 -0
- nn/runtime.py +343 -0
- nn/scorer.py +175 -0
- nn/trainer.py +515 -0
- nn/vision.py +352 -0
- personality/__init__.py +23 -0
- personality/engine.py +129 -0
- personality/identity.py +144 -0
- personality/inner_voice.py +100 -0
- personality/mood.py +205 -0
- planning/__init__.py +0 -0
- planning/dev_server.py +221 -0
- planning/forge_models.py +718 -0
- planning/orchestrator.py +1363 -0
- planning/planner.py +451 -0
- planning/task_graph.py +61 -0
- reflection/__init__.py +0 -0
- reflection/meta_learner.py +156 -0
- reflection/reflector.py +127 -0
- ui/__init__.py +5 -0
- ui/display.py +88 -0
- voice/__init__.py +0 -0
- voice/conversation.py +125 -0
- voice/listener.py +111 -0
- voice/speaker.py +59 -0
- voice/stt.py +126 -0
- 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))
|