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/statusline.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Status line — single-line status above the input prompt.
|
|
2
|
+
|
|
3
|
+
The format is a Python `str.format` template pulled from settings
|
|
4
|
+
(`statusline` key). Available placeholders:
|
|
5
|
+
|
|
6
|
+
{model} active model id
|
|
7
|
+
{mood} personality mood label or 'n/a'
|
|
8
|
+
{session} full session id
|
|
9
|
+
{session_short} first 8 chars of session id
|
|
10
|
+
{tokens} total tokens this session
|
|
11
|
+
{cost} total $ cost this session
|
|
12
|
+
{turn} message count
|
|
13
|
+
|
|
14
|
+
Missing fields render as the literal placeholder so a malformed template
|
|
15
|
+
doesn't crash the prompt — render once, decide it's wrong, re-render.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class _SafeMap(dict):
|
|
24
|
+
def __missing__(self, key: str) -> str:
|
|
25
|
+
return "{" + key + "}"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def render_statusline(template: str, values: dict[str, Any]) -> str:
|
|
29
|
+
"""Format `template` using {placeholders}; missing keys are kept literal."""
|
|
30
|
+
try:
|
|
31
|
+
return template.format_map(_SafeMap(values))
|
|
32
|
+
except Exception:
|
|
33
|
+
return template
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def build_status_values(agent: Any) -> dict[str, Any]:
|
|
37
|
+
"""Pull every documented placeholder value off a CognosAgent."""
|
|
38
|
+
from core.usage import get_global_tracker
|
|
39
|
+
tracker = get_global_tracker()
|
|
40
|
+
mood = "n/a"
|
|
41
|
+
try:
|
|
42
|
+
if agent.personality is not None:
|
|
43
|
+
mood = agent.personality.mood.label()
|
|
44
|
+
except Exception:
|
|
45
|
+
pass
|
|
46
|
+
session_id = ""
|
|
47
|
+
turn = 0
|
|
48
|
+
try:
|
|
49
|
+
session_id = agent.session.id
|
|
50
|
+
turn = len(agent.session.messages)
|
|
51
|
+
except Exception:
|
|
52
|
+
pass
|
|
53
|
+
return {
|
|
54
|
+
"model": getattr(agent.llm, "model", "?"),
|
|
55
|
+
"mood": mood,
|
|
56
|
+
"session": session_id,
|
|
57
|
+
"session_short": session_id[:8],
|
|
58
|
+
"tokens": tracker.total_tokens(),
|
|
59
|
+
"cost": tracker.total_cost(),
|
|
60
|
+
"turn": turn,
|
|
61
|
+
}
|
core/subagent.py
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""Subagent manager — spawn, track, and collect isolated child agents.
|
|
2
|
+
|
|
3
|
+
A subagent is a child AgenticLoop with its own message history, its own
|
|
4
|
+
system prompt, and a restricted tool subset. It shares the parent's
|
|
5
|
+
long-term memory (episodic/semantic) but in read-only mode — the subagent
|
|
6
|
+
does not consolidate lessons back.
|
|
7
|
+
|
|
8
|
+
Subagents can run sequentially or in parallel. Parallel execution is
|
|
9
|
+
bounded by `max_concurrent` to avoid overloading local LLMs.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import logging
|
|
16
|
+
import uuid
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from core.agentic_loop import AgenticLoop
|
|
21
|
+
from core.hooks import HookEvent, HookManager
|
|
22
|
+
from core.permissions import PermissionManager
|
|
23
|
+
from core.worktree import Worktree, create_worktree
|
|
24
|
+
from execution.executor import Executor
|
|
25
|
+
from llm.provider import LLMProvider
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
SUBAGENT_PROFILES: dict[str, dict[str, Any]] = {
|
|
31
|
+
"general-purpose": {
|
|
32
|
+
"tools": ["Read", "Write", "Edit", "Glob", "Grep", "Bash", "PythonExec",
|
|
33
|
+
"WebSearch", "WebFetch", "Think", "Respond"],
|
|
34
|
+
"system": (
|
|
35
|
+
"You are a general-purpose subagent. Focus narrowly on the task you "
|
|
36
|
+
"were given. Use tools as needed. When done, respond with a concise "
|
|
37
|
+
"summary — that summary is returned to your parent agent."
|
|
38
|
+
),
|
|
39
|
+
},
|
|
40
|
+
"research": {
|
|
41
|
+
"tools": ["WebSearch", "WebFetch", "Think", "Respond"],
|
|
42
|
+
"system": (
|
|
43
|
+
"You are a research subagent. Search the web, fetch and read pages, "
|
|
44
|
+
"then synthesize findings. Cite sources. End with a concise summary."
|
|
45
|
+
),
|
|
46
|
+
},
|
|
47
|
+
"code-review": {
|
|
48
|
+
"tools": ["Read", "Glob", "Grep", "Think", "Respond"],
|
|
49
|
+
"system": (
|
|
50
|
+
"You are a code-review subagent. Read the specified files, identify "
|
|
51
|
+
"bugs/smells/risks, and report a prioritized list with file:line "
|
|
52
|
+
"references. Do NOT modify files — review only."
|
|
53
|
+
),
|
|
54
|
+
},
|
|
55
|
+
# Dual-brain roles — mirror /home/raveuk/src/prompts/system_prompts.json.
|
|
56
|
+
# `executor_brain_01` runs linear plans; on twice-failed validation
|
|
57
|
+
# it dispatches context to `critic_brain_02` through the inter-agent
|
|
58
|
+
# bus and waits for a reply. `critic_brain_02` reviews structural
|
|
59
|
+
# problems and outputs fixes exclusively as diff patches.
|
|
60
|
+
"executor_brain": {
|
|
61
|
+
"tools": ["Read", "Write", "Edit", "Glob", "Grep", "Bash", "PythonExec",
|
|
62
|
+
"Think", "Respond", "Agent"],
|
|
63
|
+
"system": (
|
|
64
|
+
"ROLE: You are 'executor_brain_01'. Run tasks linearly. If "
|
|
65
|
+
"validation fails twice, escalate via inter_agent_bus to "
|
|
66
|
+
"critic_brain_02 with the failing context attached. Wait for "
|
|
67
|
+
"the critic's reply before retrying."
|
|
68
|
+
),
|
|
69
|
+
},
|
|
70
|
+
"critic_brain": {
|
|
71
|
+
"tools": ["Read", "Glob", "Grep", "Think", "Respond"],
|
|
72
|
+
"system": (
|
|
73
|
+
"ROLE: You are 'critic_brain_02'. You review structural "
|
|
74
|
+
"problems and output fixes exclusively as diff patches. "
|
|
75
|
+
"Read the failing context, identify the root cause, and "
|
|
76
|
+
"emit a unified diff (`---/+++` headers, `@@` hunks) that "
|
|
77
|
+
"the executor can apply directly. Do not modify files "
|
|
78
|
+
"yourself — review only."
|
|
79
|
+
),
|
|
80
|
+
},
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# ---------------------------------------------------------------------------
|
|
85
|
+
# Inter-agent bus — async queue channels between named agents.
|
|
86
|
+
#
|
|
87
|
+
# Mirrors /home/raveuk/src/communication/bus.py: one queue per registered
|
|
88
|
+
# agent id, plus a futures dict for request/reply when the sender wants
|
|
89
|
+
# to suspend until the recipient replies. Lives inline (no new file)
|
|
90
|
+
# because it's tiny and only the SubagentManager wires it.
|
|
91
|
+
# ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class InterAgentBus:
|
|
95
|
+
"""Async message bus keyed by agent id (e.g. executor_brain_01)."""
|
|
96
|
+
|
|
97
|
+
def __init__(self, agent_ids: list[str] | None = None):
|
|
98
|
+
ids = agent_ids or ["executor_brain_01", "critic_brain_02"]
|
|
99
|
+
self.channels: dict[str, asyncio.Queue] = {a: asyncio.Queue() for a in ids}
|
|
100
|
+
self.pending: dict[str, asyncio.Future] = {}
|
|
101
|
+
|
|
102
|
+
def register(self, agent_id: str) -> None:
|
|
103
|
+
self.channels.setdefault(agent_id, asyncio.Queue())
|
|
104
|
+
|
|
105
|
+
async def send(
|
|
106
|
+
self,
|
|
107
|
+
sender_id: str,
|
|
108
|
+
recipient_id: str,
|
|
109
|
+
context: str,
|
|
110
|
+
suspend_sender: bool = False,
|
|
111
|
+
) -> Any | None:
|
|
112
|
+
if recipient_id not in self.channels:
|
|
113
|
+
raise ValueError(f"Bus address '{recipient_id}' is not registered.")
|
|
114
|
+
payload: dict[str, Any] = {
|
|
115
|
+
"sender_id": sender_id,
|
|
116
|
+
"context": context,
|
|
117
|
+
"requires_reply": suspend_sender,
|
|
118
|
+
}
|
|
119
|
+
if suspend_sender:
|
|
120
|
+
loop = asyncio.get_event_loop()
|
|
121
|
+
fut: asyncio.Future = loop.create_future()
|
|
122
|
+
corr_id = f"{sender_id}->{recipient_id}:{id(fut)}"
|
|
123
|
+
payload["correlation_id"] = corr_id
|
|
124
|
+
self.pending[corr_id] = fut
|
|
125
|
+
await self.channels[recipient_id].put(payload)
|
|
126
|
+
try:
|
|
127
|
+
return await fut
|
|
128
|
+
finally:
|
|
129
|
+
self.pending.pop(corr_id, None)
|
|
130
|
+
await self.channels[recipient_id].put(payload)
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
async def recv(self, agent_id: str) -> dict[str, Any]:
|
|
134
|
+
if agent_id not in self.channels:
|
|
135
|
+
raise ValueError(f"Bus address '{agent_id}' is not registered.")
|
|
136
|
+
return await self.channels[agent_id].get()
|
|
137
|
+
|
|
138
|
+
def reply(self, correlation_id: str, result: Any) -> bool:
|
|
139
|
+
fut = self.pending.get(correlation_id)
|
|
140
|
+
if fut is None or fut.done():
|
|
141
|
+
return False
|
|
142
|
+
fut.set_result(result)
|
|
143
|
+
return True
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@dataclass
|
|
147
|
+
class SubagentRun:
|
|
148
|
+
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
149
|
+
subagent_type: str = "general-purpose"
|
|
150
|
+
task: str = ""
|
|
151
|
+
result: str | None = None
|
|
152
|
+
error: str | None = None
|
|
153
|
+
worktree_path: str | None = None
|
|
154
|
+
worktree_branch: str | None = None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class SubagentManager:
|
|
158
|
+
"""Spawns child agents with restricted tool sets."""
|
|
159
|
+
|
|
160
|
+
def __init__(
|
|
161
|
+
self,
|
|
162
|
+
llm: LLMProvider,
|
|
163
|
+
parent_executor: Executor,
|
|
164
|
+
episodic=None,
|
|
165
|
+
semantic=None,
|
|
166
|
+
permissions: PermissionManager | None = None,
|
|
167
|
+
hooks: HookManager | None = None,
|
|
168
|
+
max_concurrent: int = 3,
|
|
169
|
+
max_iterations: int = 10,
|
|
170
|
+
):
|
|
171
|
+
self.llm = llm
|
|
172
|
+
self.parent_executor = parent_executor
|
|
173
|
+
self.episodic = episodic
|
|
174
|
+
self.semantic = semantic
|
|
175
|
+
self.permissions = permissions
|
|
176
|
+
self.hooks = hooks or HookManager()
|
|
177
|
+
self.max_concurrent = max_concurrent
|
|
178
|
+
self.max_iterations = max_iterations
|
|
179
|
+
self._semaphore = asyncio.Semaphore(max_concurrent)
|
|
180
|
+
# Inter-agent bus — pre-registers the two dual-brain addresses.
|
|
181
|
+
# Additional ids can be added via `self.bus.register(...)`.
|
|
182
|
+
self.bus = InterAgentBus()
|
|
183
|
+
|
|
184
|
+
async def dispatch(
|
|
185
|
+
self,
|
|
186
|
+
sender_id: str,
|
|
187
|
+
recipient_id: str,
|
|
188
|
+
context: str,
|
|
189
|
+
suspend_sender: bool = False,
|
|
190
|
+
) -> Any | None:
|
|
191
|
+
"""Send a message on the inter-agent bus.
|
|
192
|
+
|
|
193
|
+
When `suspend_sender` is True, awaits a reply from the recipient
|
|
194
|
+
and returns it. Otherwise fire-and-forget.
|
|
195
|
+
"""
|
|
196
|
+
return await self.bus.send(sender_id, recipient_id, context, suspend_sender)
|
|
197
|
+
|
|
198
|
+
async def run_one(
|
|
199
|
+
self,
|
|
200
|
+
task: str,
|
|
201
|
+
subagent_type: str = "general-purpose",
|
|
202
|
+
isolation: str | None = None,
|
|
203
|
+
) -> SubagentRun:
|
|
204
|
+
"""Run a single subagent to completion.
|
|
205
|
+
|
|
206
|
+
`isolation="worktree"` creates a temporary git worktree the
|
|
207
|
+
subagent operates in. The worktree path is returned on the run
|
|
208
|
+
so the caller can review or discard it. If creation fails, the
|
|
209
|
+
run continues in the parent's working tree (best-effort).
|
|
210
|
+
"""
|
|
211
|
+
profile = SUBAGENT_PROFILES.get(subagent_type, SUBAGENT_PROFILES["general-purpose"])
|
|
212
|
+
run = SubagentRun(subagent_type=subagent_type, task=task)
|
|
213
|
+
|
|
214
|
+
await self.hooks.emit(HookEvent.SUBAGENT_START, {
|
|
215
|
+
"subagent_id": run.id,
|
|
216
|
+
"subagent_type": subagent_type,
|
|
217
|
+
"task": task,
|
|
218
|
+
"isolation": isolation,
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
wt: Worktree | None = None
|
|
222
|
+
if isolation == "worktree":
|
|
223
|
+
wt = await create_worktree()
|
|
224
|
+
if wt is not None:
|
|
225
|
+
run.worktree_path = str(wt.path)
|
|
226
|
+
run.worktree_branch = wt.branch
|
|
227
|
+
logger.info(f"Subagent {run.id} running in worktree {wt.path}")
|
|
228
|
+
|
|
229
|
+
async with self._semaphore:
|
|
230
|
+
try:
|
|
231
|
+
child_executor = _restricted_executor(
|
|
232
|
+
self.parent_executor, profile["tools"], self.llm,
|
|
233
|
+
)
|
|
234
|
+
child = AgenticLoop(
|
|
235
|
+
llm=self.llm,
|
|
236
|
+
executor=child_executor,
|
|
237
|
+
episodic=self.episodic,
|
|
238
|
+
semantic=self.semantic,
|
|
239
|
+
system_prompt=profile["system"],
|
|
240
|
+
max_iterations=self.max_iterations,
|
|
241
|
+
permissions=self.permissions,
|
|
242
|
+
)
|
|
243
|
+
if wt is not None:
|
|
244
|
+
# Tell tools the new working directory by chdir-ing
|
|
245
|
+
# for the duration of this run. AgenticLoop is async
|
|
246
|
+
# but we keep this call boundary serialized via the
|
|
247
|
+
# semaphore, so a process-level chdir is OK here.
|
|
248
|
+
import os
|
|
249
|
+
prev_cwd = os.getcwd()
|
|
250
|
+
os.chdir(wt.path)
|
|
251
|
+
try:
|
|
252
|
+
run.result = await child.run(task)
|
|
253
|
+
finally:
|
|
254
|
+
os.chdir(prev_cwd)
|
|
255
|
+
else:
|
|
256
|
+
run.result = await child.run(task)
|
|
257
|
+
except Exception as e:
|
|
258
|
+
logger.warning(f"Subagent {run.id} failed: {e}")
|
|
259
|
+
run.error = str(e)
|
|
260
|
+
finally:
|
|
261
|
+
if wt is not None:
|
|
262
|
+
await wt.cleanup(force=False)
|
|
263
|
+
|
|
264
|
+
await self.hooks.emit(HookEvent.SUBAGENT_STOP, {
|
|
265
|
+
"subagent_id": run.id,
|
|
266
|
+
"result": run.result,
|
|
267
|
+
"error": run.error,
|
|
268
|
+
"worktree_path": run.worktree_path,
|
|
269
|
+
})
|
|
270
|
+
return run
|
|
271
|
+
|
|
272
|
+
async def run_parallel(
|
|
273
|
+
self,
|
|
274
|
+
tasks: list[tuple[str, str]],
|
|
275
|
+
isolation: str | None = None,
|
|
276
|
+
) -> list[SubagentRun]:
|
|
277
|
+
"""Run multiple subagents concurrently. Each tuple = (task, subagent_type)."""
|
|
278
|
+
return await asyncio.gather(*(
|
|
279
|
+
self.run_one(task, t, isolation=isolation) for task, t in tasks
|
|
280
|
+
))
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _restricted_executor(
|
|
284
|
+
parent: Executor,
|
|
285
|
+
allowed_tools: list[str],
|
|
286
|
+
llm: LLMProvider,
|
|
287
|
+
) -> Executor:
|
|
288
|
+
"""Create a new executor exposing only the allowed tools.
|
|
289
|
+
|
|
290
|
+
Rather than deep-copying the parent, build a fresh Executor with the
|
|
291
|
+
allowed subset. This keeps the restriction airtight.
|
|
292
|
+
"""
|
|
293
|
+
child = Executor.__new__(Executor)
|
|
294
|
+
child._tools = {}
|
|
295
|
+
child._llm = llm
|
|
296
|
+
for name in allowed_tools:
|
|
297
|
+
tool = parent.get_tool(name)
|
|
298
|
+
if tool is not None:
|
|
299
|
+
child._tools[tool.name] = tool
|
|
300
|
+
return child
|
core/thinking.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""ThinkingManager — chain-of-thought reasoning before actions.
|
|
2
|
+
|
|
3
|
+
Mirrors the Claude SDK "thinking" feature using a prompt-based protocol:
|
|
4
|
+
the model is instructed to emit <thinking>...</thinking> blocks before any
|
|
5
|
+
visible output or tool call. The manager strips those blocks from the
|
|
6
|
+
user-facing content and exposes them separately so the UI can render them
|
|
7
|
+
in a dimmed panel (or ignore them silently by default).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
|
|
14
|
+
from core.schemas import ThinkingBlock
|
|
15
|
+
|
|
16
|
+
THINKING_INSTRUCTION = (
|
|
17
|
+
"Before responding or calling any tool, you may reason step-by-step "
|
|
18
|
+
"inside a <thinking>...</thinking> block. Use it to plan your approach, "
|
|
19
|
+
"weigh options, and catch mistakes. Everything inside the block is "
|
|
20
|
+
"internal and will not be shown to the user. Keep it brief — one short "
|
|
21
|
+
"paragraph is usually enough. After the thinking block, either call a "
|
|
22
|
+
"tool or emit your final answer."
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
_THINKING_RE = re.compile(
|
|
26
|
+
r"<thinking>(.*?)</thinking>",
|
|
27
|
+
re.DOTALL | re.IGNORECASE,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ThinkingManager:
|
|
32
|
+
"""Injects thinking instructions into the system prompt and parses blocks."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, enabled: bool = False):
|
|
35
|
+
self.enabled = enabled
|
|
36
|
+
|
|
37
|
+
def augment_system_prompt(self, system: str) -> str:
|
|
38
|
+
"""Append the thinking instruction to a system prompt."""
|
|
39
|
+
if not self.enabled:
|
|
40
|
+
return system
|
|
41
|
+
return f"{system}\n\n{THINKING_INSTRUCTION}"
|
|
42
|
+
|
|
43
|
+
def parse(self, text: str) -> tuple[list[ThinkingBlock], str]:
|
|
44
|
+
"""Extract thinking blocks from text. Returns (blocks, stripped_text)."""
|
|
45
|
+
blocks = [
|
|
46
|
+
ThinkingBlock(thinking=m.group(1).strip())
|
|
47
|
+
for m in _THINKING_RE.finditer(text)
|
|
48
|
+
]
|
|
49
|
+
stripped = _THINKING_RE.sub("", text).strip()
|
|
50
|
+
return blocks, stripped
|
core/updater.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Auto-update — `cognos update` checks for and applies new versions.
|
|
2
|
+
|
|
3
|
+
Two install modes are detected automatically:
|
|
4
|
+
|
|
5
|
+
1. **Git checkout** (the common case for a cloned repo) — runs
|
|
6
|
+
`git fetch && git pull --ff-only` on the project root. If the local
|
|
7
|
+
branch has diverged, refuses to merge and tells the user.
|
|
8
|
+
|
|
9
|
+
2. **Pip install** (if `cognos` was installed as a package) — runs
|
|
10
|
+
`pip install --upgrade cognos`. We detect this by looking for an
|
|
11
|
+
installed distribution metadata entry; absent → assume git mode.
|
|
12
|
+
|
|
13
|
+
Returns a small report describing what changed, or why nothing did.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import logging
|
|
19
|
+
import shutil
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class UpdateResult:
|
|
30
|
+
mode: str # "git" | "pip" | "unknown"
|
|
31
|
+
changed: bool
|
|
32
|
+
summary: str
|
|
33
|
+
details: str = ""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _is_git_repo(path: Path) -> bool:
|
|
37
|
+
return (path / ".git").exists()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _pip_installed() -> bool:
|
|
41
|
+
try:
|
|
42
|
+
from importlib.metadata import distribution
|
|
43
|
+
distribution("cognos")
|
|
44
|
+
return True
|
|
45
|
+
except Exception:
|
|
46
|
+
return False
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _run(cmd: list[str], cwd: Path | None = None, timeout: int = 120) -> tuple[int, str, str]:
|
|
50
|
+
proc = subprocess.run(
|
|
51
|
+
cmd,
|
|
52
|
+
cwd=cwd,
|
|
53
|
+
capture_output=True,
|
|
54
|
+
text=True,
|
|
55
|
+
timeout=timeout,
|
|
56
|
+
)
|
|
57
|
+
return proc.returncode, proc.stdout.strip(), proc.stderr.strip()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def update_via_git(repo: Path) -> UpdateResult:
|
|
61
|
+
if shutil.which("git") is None:
|
|
62
|
+
return UpdateResult(mode="git", changed=False, summary="git not installed")
|
|
63
|
+
|
|
64
|
+
rc, before, _ = _run(["git", "rev-parse", "HEAD"], cwd=repo)
|
|
65
|
+
if rc != 0:
|
|
66
|
+
return UpdateResult(mode="git", changed=False, summary="not a git checkout")
|
|
67
|
+
|
|
68
|
+
rc, _, err = _run(["git", "fetch"], cwd=repo)
|
|
69
|
+
if rc != 0:
|
|
70
|
+
return UpdateResult(mode="git", changed=False, summary="fetch failed", details=err)
|
|
71
|
+
|
|
72
|
+
rc, out, err = _run(["git", "pull", "--ff-only"], cwd=repo)
|
|
73
|
+
if rc != 0:
|
|
74
|
+
return UpdateResult(
|
|
75
|
+
mode="git",
|
|
76
|
+
changed=False,
|
|
77
|
+
summary="local branch has diverged — resolve manually",
|
|
78
|
+
details=err or out,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
rc, after, _ = _run(["git", "rev-parse", "HEAD"], cwd=repo)
|
|
82
|
+
if before == after:
|
|
83
|
+
return UpdateResult(mode="git", changed=False, summary="already up to date")
|
|
84
|
+
rc, log, _ = _run(
|
|
85
|
+
["git", "log", "--oneline", f"{before}..{after}"], cwd=repo,
|
|
86
|
+
)
|
|
87
|
+
return UpdateResult(
|
|
88
|
+
mode="git",
|
|
89
|
+
changed=True,
|
|
90
|
+
summary=f"updated {before[:7]} → {after[:7]}",
|
|
91
|
+
details=log,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def update_via_pip() -> UpdateResult:
|
|
96
|
+
rc, out, err = _run(
|
|
97
|
+
[sys.executable, "-m", "pip", "install", "--upgrade", "cognos"],
|
|
98
|
+
timeout=300,
|
|
99
|
+
)
|
|
100
|
+
if rc != 0:
|
|
101
|
+
return UpdateResult(mode="pip", changed=False, summary="pip upgrade failed", details=err or out)
|
|
102
|
+
changed = "already satisfied" not in (out + err).lower()
|
|
103
|
+
return UpdateResult(
|
|
104
|
+
mode="pip",
|
|
105
|
+
changed=changed,
|
|
106
|
+
summary="pip upgrade ok" if changed else "already up to date",
|
|
107
|
+
details=out,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def update(project_root: Path | None = None) -> UpdateResult:
|
|
112
|
+
"""Detect install mode and update accordingly."""
|
|
113
|
+
root = project_root or Path(__file__).resolve().parent.parent
|
|
114
|
+
if _is_git_repo(root):
|
|
115
|
+
return update_via_git(root)
|
|
116
|
+
if _pip_installed():
|
|
117
|
+
return update_via_pip()
|
|
118
|
+
return UpdateResult(
|
|
119
|
+
mode="unknown",
|
|
120
|
+
changed=False,
|
|
121
|
+
summary="not a git checkout and not pip-installed — cannot self-update",
|
|
122
|
+
)
|
core/usage.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Usage tracking — token counts and rough cost across an agent session.
|
|
2
|
+
|
|
3
|
+
`UsageTracker` accumulates per-model token counts (prompt + completion)
|
|
4
|
+
and computes cost from a static price table. Local Ollama models are
|
|
5
|
+
priced at $0 by default. Cloud models use approximate prices that match
|
|
6
|
+
their published rates as of early 2026 — adjust freely; the tracker just
|
|
7
|
+
multiplies tokens × $/Mtok.
|
|
8
|
+
|
|
9
|
+
The tracker is mutable shared state — `LLMProvider` calls `record()` on
|
|
10
|
+
every successful response, so any consumer (status line, /cost slash
|
|
11
|
+
command, end-of-session report) sees the live total.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# Price per 1M tokens, USD. Best-effort, intentionally conservative.
|
|
24
|
+
# Anything not listed defaults to 0 (treated as local/free).
|
|
25
|
+
DEFAULT_PRICES: dict[str, tuple[float, float]] = {
|
|
26
|
+
# Anthropic
|
|
27
|
+
"anthropic/claude-opus-4-7": (15.0, 75.0),
|
|
28
|
+
"anthropic/claude-sonnet-4-6": (3.0, 15.0),
|
|
29
|
+
"anthropic/claude-haiku-4-5": (0.80, 4.0),
|
|
30
|
+
"claude-opus-4-7": (15.0, 75.0),
|
|
31
|
+
"claude-sonnet-4-6": (3.0, 15.0),
|
|
32
|
+
"claude-haiku-4-5": (0.80, 4.0),
|
|
33
|
+
# OpenAI rough rates
|
|
34
|
+
"openai/gpt-4o": (2.5, 10.0),
|
|
35
|
+
"openai/gpt-4o-mini": (0.15, 0.6),
|
|
36
|
+
"gpt-4o": (2.5, 10.0),
|
|
37
|
+
"gpt-4o-mini": (0.15, 0.6),
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class ModelUsage:
|
|
43
|
+
prompt_tokens: int = 0
|
|
44
|
+
completion_tokens: int = 0
|
|
45
|
+
requests: int = 0
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def total_tokens(self) -> int:
|
|
49
|
+
return self.prompt_tokens + self.completion_tokens
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class UsageTracker:
|
|
54
|
+
"""Accumulates usage across a session."""
|
|
55
|
+
|
|
56
|
+
by_model: dict[str, ModelUsage] = field(default_factory=dict)
|
|
57
|
+
prices: dict[str, tuple[float, float]] = field(
|
|
58
|
+
default_factory=lambda: dict(DEFAULT_PRICES),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def record(self, model: str, usage: dict[str, Any]) -> None:
|
|
62
|
+
"""Record a single LLM call.
|
|
63
|
+
|
|
64
|
+
`usage` is the dict shape LLMProvider returns: {prompt_tokens,
|
|
65
|
+
completion_tokens, total_tokens}. Missing keys are treated as 0.
|
|
66
|
+
"""
|
|
67
|
+
slot = self.by_model.setdefault(model, ModelUsage())
|
|
68
|
+
slot.prompt_tokens += int(usage.get("prompt_tokens") or 0)
|
|
69
|
+
slot.completion_tokens += int(usage.get("completion_tokens") or 0)
|
|
70
|
+
slot.requests += 1
|
|
71
|
+
|
|
72
|
+
def total_tokens(self) -> int:
|
|
73
|
+
return sum(u.total_tokens for u in self.by_model.values())
|
|
74
|
+
|
|
75
|
+
def total_cost(self) -> float:
|
|
76
|
+
total = 0.0
|
|
77
|
+
for model, u in self.by_model.items():
|
|
78
|
+
in_price, out_price = self.prices.get(model, (0.0, 0.0))
|
|
79
|
+
total += (u.prompt_tokens / 1_000_000) * in_price
|
|
80
|
+
total += (u.completion_tokens / 1_000_000) * out_price
|
|
81
|
+
return total
|
|
82
|
+
|
|
83
|
+
def report(self) -> dict[str, Any]:
|
|
84
|
+
return {
|
|
85
|
+
"total_tokens": self.total_tokens(),
|
|
86
|
+
"total_cost_usd": round(self.total_cost(), 6),
|
|
87
|
+
"by_model": {
|
|
88
|
+
m: {
|
|
89
|
+
"prompt_tokens": u.prompt_tokens,
|
|
90
|
+
"completion_tokens": u.completion_tokens,
|
|
91
|
+
"requests": u.requests,
|
|
92
|
+
}
|
|
93
|
+
for m, u in self.by_model.items()
|
|
94
|
+
},
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# Process-wide tracker. Shared by LLMProvider so any agent in the process
|
|
99
|
+
# accumulates into the same totals. Tests can replace this if isolation
|
|
100
|
+
# matters.
|
|
101
|
+
_GLOBAL = UsageTracker()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def get_global_tracker() -> UsageTracker:
|
|
105
|
+
return _GLOBAL
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def reset_global_tracker() -> None:
|
|
109
|
+
_GLOBAL.by_model.clear()
|