bareagent-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.
- bareagent/__init__.py +10 -0
- bareagent/concurrency/__init__.py +6 -0
- bareagent/concurrency/background.py +97 -0
- bareagent/concurrency/notification.py +61 -0
- bareagent/concurrency/scheduler.py +136 -0
- bareagent/config.toml +299 -0
- bareagent/core/__init__.py +1 -0
- bareagent/core/config_paths.py +49 -0
- bareagent/core/context.py +127 -0
- bareagent/core/fileutil.py +103 -0
- bareagent/core/goal.py +214 -0
- bareagent/core/handlers/__init__.py +1 -0
- bareagent/core/handlers/bash.py +79 -0
- bareagent/core/handlers/file_edit.py +47 -0
- bareagent/core/handlers/file_read.py +270 -0
- bareagent/core/handlers/file_write.py +34 -0
- bareagent/core/handlers/glob_search.py +30 -0
- bareagent/core/handlers/goal.py +60 -0
- bareagent/core/handlers/grep_search.py +52 -0
- bareagent/core/handlers/memory.py +71 -0
- bareagent/core/handlers/plan.py +106 -0
- bareagent/core/handlers/search_utils.py +77 -0
- bareagent/core/handlers/skill.py +87 -0
- bareagent/core/handlers/subagent_send.py +70 -0
- bareagent/core/handlers/web_fetch.py +126 -0
- bareagent/core/handlers/web_search.py +165 -0
- bareagent/core/handlers/workflow.py +190 -0
- bareagent/core/loop.py +535 -0
- bareagent/core/retry.py +131 -0
- bareagent/core/sandbox.py +27 -0
- bareagent/core/schema.py +21 -0
- bareagent/core/tools.py +779 -0
- bareagent/core/workflow.py +517 -0
- bareagent/core/workflow_registry.py +219 -0
- bareagent/debug/__init__.py +0 -0
- bareagent/debug/interaction_log.py +263 -0
- bareagent/debug/viewer.html +1750 -0
- bareagent/debug/web_viewer.py +157 -0
- bareagent/hooks/__init__.py +32 -0
- bareagent/hooks/config.py +118 -0
- bareagent/hooks/engine.py +197 -0
- bareagent/hooks/errors.py +14 -0
- bareagent/hooks/events.py +22 -0
- bareagent/lsp/__init__.py +63 -0
- bareagent/lsp/config.py +134 -0
- bareagent/lsp/coord.py +118 -0
- bareagent/lsp/diagnostics.py +240 -0
- bareagent/lsp/errors.py +24 -0
- bareagent/lsp/manager.py +866 -0
- bareagent/lsp/tools.py +629 -0
- bareagent/lsp/workspace_edit.py +305 -0
- bareagent/main.py +4205 -0
- bareagent/mcp/__init__.py +69 -0
- bareagent/mcp/_sse.py +69 -0
- bareagent/mcp/client.py +341 -0
- bareagent/mcp/config.py +169 -0
- bareagent/mcp/errors.py +32 -0
- bareagent/mcp/manager.py +318 -0
- bareagent/mcp/protocol.py +187 -0
- bareagent/mcp/registry.py +557 -0
- bareagent/mcp/transport/__init__.py +15 -0
- bareagent/mcp/transport/base.py +149 -0
- bareagent/mcp/transport/http_legacy.py +192 -0
- bareagent/mcp/transport/http_streamable.py +217 -0
- bareagent/mcp/transport/stdio.py +202 -0
- bareagent/memory/__init__.py +1 -0
- bareagent/memory/compact.py +203 -0
- bareagent/memory/conversation_io.py +226 -0
- bareagent/memory/embedding.py +194 -0
- bareagent/memory/persistent.py +515 -0
- bareagent/memory/token_counter.py +67 -0
- bareagent/memory/token_tracker.py +262 -0
- bareagent/memory/transcript.py +100 -0
- bareagent/permission/__init__.py +1 -0
- bareagent/permission/guard.py +329 -0
- bareagent/permission/rules.py +19 -0
- bareagent/planning/__init__.py +19 -0
- bareagent/planning/agent_types.py +169 -0
- bareagent/planning/skill_gen.py +141 -0
- bareagent/planning/skill_store.py +173 -0
- bareagent/planning/skills.py +146 -0
- bareagent/planning/subagent.py +355 -0
- bareagent/planning/subagent_registry.py +77 -0
- bareagent/planning/tasks.py +348 -0
- bareagent/planning/todo.py +153 -0
- bareagent/planning/worktree.py +122 -0
- bareagent/provider/__init__.py +1 -0
- bareagent/provider/anthropic.py +348 -0
- bareagent/provider/base.py +136 -0
- bareagent/provider/factory.py +130 -0
- bareagent/provider/openai.py +881 -0
- bareagent/provider/presets.py +72 -0
- bareagent/provider/setup.py +356 -0
- bareagent/skills/.gitkeep +1 -0
- bareagent/skills/code-review/SKILL.md +68 -0
- bareagent/skills/git/SKILL.md +68 -0
- bareagent/skills/test/SKILL.md +70 -0
- bareagent/team/__init__.py +17 -0
- bareagent/team/autonomous.py +193 -0
- bareagent/team/mailbox.py +239 -0
- bareagent/team/manager.py +155 -0
- bareagent/team/protocols.py +129 -0
- bareagent/tracing/__init__.py +12 -0
- bareagent/tracing/_api.py +92 -0
- bareagent/tracing/_proxy.py +60 -0
- bareagent/tracing/composite.py +115 -0
- bareagent/tracing/json_file.py +115 -0
- bareagent/tracing/langfuse.py +139 -0
- bareagent/tracing/otel.py +107 -0
- bareagent/tracing/setup.py +85 -0
- bareagent/ui/__init__.py +24 -0
- bareagent/ui/console.py +167 -0
- bareagent/ui/prompt.py +78 -0
- bareagent/ui/protocol.py +24 -0
- bareagent/ui/stream.py +66 -0
- bareagent/ui/theme.py +240 -0
- bareagent_cli-0.1.0.dist-info/METADATA +331 -0
- bareagent_cli-0.1.0.dist-info/RECORD +121 -0
- bareagent_cli-0.1.0.dist-info/WHEEL +4 -0
- bareagent_cli-0.1.0.dist-info/entry_points.txt +2 -0
- bareagent_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
bareagent/main.py
ADDED
|
@@ -0,0 +1,4205 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import atexit
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import signal
|
|
9
|
+
import sys
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
import tomllib
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from collections.abc import Set as AbstractSet
|
|
15
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
16
|
+
from dataclasses import asdict, dataclass, field, replace
|
|
17
|
+
from datetime import datetime
|
|
18
|
+
from functools import partial
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from types import SimpleNamespace
|
|
21
|
+
from typing import Any, Literal, cast
|
|
22
|
+
|
|
23
|
+
from bareagent.concurrency.background import BackgroundManager
|
|
24
|
+
from bareagent.concurrency.scheduler import Scheduler, SchedulerError
|
|
25
|
+
from bareagent.core.config_paths import DEFAULT_CONFIG_PATH, local_config_path
|
|
26
|
+
from bareagent.core.context import PLAN_MODE_DIRECTIVE, assemble_system_prompt
|
|
27
|
+
from bareagent.core.fileutil import (
|
|
28
|
+
atomic_write_text,
|
|
29
|
+
generate_random_id,
|
|
30
|
+
is_tool_result_message,
|
|
31
|
+
)
|
|
32
|
+
from bareagent.core.fileutil import (
|
|
33
|
+
optional_string as _coerce_optional_string,
|
|
34
|
+
)
|
|
35
|
+
from bareagent.core.goal import (
|
|
36
|
+
DEFAULT_MAX_TURNS,
|
|
37
|
+
GoalOutcome,
|
|
38
|
+
GoalState,
|
|
39
|
+
Verdict,
|
|
40
|
+
build_evaluator_prompt,
|
|
41
|
+
parse_goal_command,
|
|
42
|
+
run_goal_loop,
|
|
43
|
+
)
|
|
44
|
+
from bareagent.core.handlers.bash import run_bash
|
|
45
|
+
from bareagent.core.handlers.goal import GOAL_VERDICT_TOOL_SCHEMA, run_goal_verdict
|
|
46
|
+
from bareagent.core.handlers.plan import (
|
|
47
|
+
EXIT_PLAN_MODE_TOOL_SCHEMA,
|
|
48
|
+
PlanDecision,
|
|
49
|
+
run_exit_plan_mode,
|
|
50
|
+
)
|
|
51
|
+
from bareagent.core.handlers.skill import SKILL_CREATE_TOOL_SCHEMA, run_skill_create
|
|
52
|
+
from bareagent.core.handlers.subagent_send import SUBAGENT_SEND_TOOL_SCHEMA, run_subagent_send
|
|
53
|
+
from bareagent.core.handlers.workflow import (
|
|
54
|
+
WORKFLOW_TOOL_SCHEMA,
|
|
55
|
+
run_workflow_tool,
|
|
56
|
+
validate_workflow_input,
|
|
57
|
+
)
|
|
58
|
+
from bareagent.core.loop import LLMCallError, agent_loop
|
|
59
|
+
from bareagent.core.retry import RetryPolicy
|
|
60
|
+
from bareagent.core.tools import get_handlers, get_tools
|
|
61
|
+
from bareagent.core.workflow import (
|
|
62
|
+
DEFAULT_MAX_CONCURRENCY,
|
|
63
|
+
DEFAULT_MAX_NODES,
|
|
64
|
+
NodeResult,
|
|
65
|
+
NodeStatus,
|
|
66
|
+
WorkflowNode,
|
|
67
|
+
build_node_prompt,
|
|
68
|
+
)
|
|
69
|
+
from bareagent.core.workflow_registry import (
|
|
70
|
+
DEFAULT_MAX_RUNS,
|
|
71
|
+
RunStatus,
|
|
72
|
+
WorkflowRegistry,
|
|
73
|
+
WorkflowRun,
|
|
74
|
+
)
|
|
75
|
+
from bareagent.debug.interaction_log import InteractionLogger
|
|
76
|
+
from bareagent.hooks import (
|
|
77
|
+
HookConfigError,
|
|
78
|
+
HookEngine,
|
|
79
|
+
HooksConfig,
|
|
80
|
+
parse_hooks_config,
|
|
81
|
+
)
|
|
82
|
+
from bareagent.lsp import (
|
|
83
|
+
LanguageServerManager,
|
|
84
|
+
LSPConfig,
|
|
85
|
+
LSPError,
|
|
86
|
+
parse_lsp_config,
|
|
87
|
+
)
|
|
88
|
+
from bareagent.mcp import MCPCallError, MCPConfig, MCPError, MCPManager, parse_mcp_config
|
|
89
|
+
from bareagent.mcp.registry import _flatten_content as _mcp_flatten_content
|
|
90
|
+
from bareagent.memory.compact import Compactor
|
|
91
|
+
from bareagent.memory.conversation_io import parse_import, render_markdown, to_export_json
|
|
92
|
+
from bareagent.memory.embedding import build_embedder
|
|
93
|
+
from bareagent.memory.persistent import (
|
|
94
|
+
MemoryManager,
|
|
95
|
+
build_forget_instruction,
|
|
96
|
+
build_remember_instruction,
|
|
97
|
+
resolve_memory_root,
|
|
98
|
+
)
|
|
99
|
+
from bareagent.memory.token_tracker import TokenTracker
|
|
100
|
+
from bareagent.memory.transcript import TranscriptManager
|
|
101
|
+
from bareagent.permission.guard import (
|
|
102
|
+
PermissionGuard,
|
|
103
|
+
PermissionMode,
|
|
104
|
+
permission_rule_subject,
|
|
105
|
+
)
|
|
106
|
+
from bareagent.permission.rules import parse_permission_rules
|
|
107
|
+
from bareagent.planning.agent_types import BUILTIN_AGENT_TYPES, DEFAULT_AGENT_TYPE
|
|
108
|
+
from bareagent.planning.skill_gen import SkillGenConfig, SkillGenerator, render_reflection_prompt
|
|
109
|
+
from bareagent.planning.skill_store import (
|
|
110
|
+
SkillStore,
|
|
111
|
+
SkillStoreError,
|
|
112
|
+
resolve_generated_skills_root,
|
|
113
|
+
)
|
|
114
|
+
from bareagent.planning.skills import LOAD_SKILL_TOOL_SCHEMAS, SkillLoader, resolve_skills_dir
|
|
115
|
+
from bareagent.planning.subagent import run_subagent
|
|
116
|
+
from bareagent.planning.subagent_registry import ResumableContext, SubagentRegistry
|
|
117
|
+
from bareagent.planning.tasks import TaskManager
|
|
118
|
+
from bareagent.planning.todo import TodoManager
|
|
119
|
+
from bareagent.provider.base import (
|
|
120
|
+
VALID_CACHE_TTLS,
|
|
121
|
+
VALID_THINKING_MODES,
|
|
122
|
+
BaseLLMProvider,
|
|
123
|
+
CacheConfig,
|
|
124
|
+
ThinkingConfig,
|
|
125
|
+
)
|
|
126
|
+
from bareagent.provider.factory import _resolve_api_key, create_provider
|
|
127
|
+
from bareagent.provider.setup import run_setup_wizard
|
|
128
|
+
from bareagent.team.autonomous import AutonomousAgent
|
|
129
|
+
from bareagent.team.mailbox import Message, MessageBus
|
|
130
|
+
from bareagent.team.manager import TeammateManager
|
|
131
|
+
from bareagent.team.protocols import Protocol, ProtocolFSM, decode_protocol_content
|
|
132
|
+
from bareagent.ui.console import AgentConsole
|
|
133
|
+
|
|
134
|
+
_log = logging.getLogger(__name__)
|
|
135
|
+
|
|
136
|
+
VALID_PERMISSION_MODES = {m.value for m in PermissionMode}
|
|
137
|
+
VALID_SUBAGENT_TYPES = set(BUILTIN_AGENT_TYPES)
|
|
138
|
+
MAIN_AGENT_NAME = "main"
|
|
139
|
+
DEFAULT_API_KEY_ENV_BY_PROVIDER = {
|
|
140
|
+
"anthropic": "ANTHROPIC_API_KEY",
|
|
141
|
+
"openai": "OPENAI_API_KEY",
|
|
142
|
+
"deepseek": "DEEPSEEK_API_KEY",
|
|
143
|
+
}
|
|
144
|
+
_SESSION_ID_TIMESTAMP_FORMAT = "%Y%m%d-%H%M%S-%f"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@dataclass(slots=True)
|
|
148
|
+
class ProviderConfig:
|
|
149
|
+
name: str
|
|
150
|
+
model: str
|
|
151
|
+
api_key_env: str
|
|
152
|
+
api_key: str | None = None
|
|
153
|
+
base_url: str | None = None
|
|
154
|
+
wire_api: str | None = None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@dataclass(slots=True)
|
|
158
|
+
class PermissionConfig:
|
|
159
|
+
mode: str
|
|
160
|
+
allow: list[str]
|
|
161
|
+
deny: list[str]
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@dataclass(slots=True)
|
|
165
|
+
class UIConfig:
|
|
166
|
+
stream: bool
|
|
167
|
+
theme: str
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@dataclass(slots=True)
|
|
171
|
+
class SubagentConfig:
|
|
172
|
+
max_depth: int
|
|
173
|
+
default_type: str
|
|
174
|
+
# Soft cap on resumable foreground subagent contexts held in the
|
|
175
|
+
# session-scoped registry; registering past it evicts the oldest. Config-only
|
|
176
|
+
# (no env override), restart-required.
|
|
177
|
+
max_resumable: int = 20
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@dataclass(slots=True)
|
|
181
|
+
class DebugConfig:
|
|
182
|
+
enabled: bool = False
|
|
183
|
+
log_dir: str = ".logs"
|
|
184
|
+
viewer_port: int = 8321
|
|
185
|
+
pretty: bool = True
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@dataclass(slots=True)
|
|
189
|
+
class TracingConfig:
|
|
190
|
+
langfuse: bool = False
|
|
191
|
+
opentelemetry: bool = False
|
|
192
|
+
content_enabled: bool = True
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@dataclass(slots=True)
|
|
196
|
+
class MemoryConfig:
|
|
197
|
+
enabled: bool = True
|
|
198
|
+
# Memory root. Empty -> per-project default under ~/.bareagent/projects/.
|
|
199
|
+
dir: str = ""
|
|
200
|
+
# Max lines of MEMORY.md injected into the system prompt at session start.
|
|
201
|
+
max_index_lines: int = 200
|
|
202
|
+
# Number of relevant memories recalled and injected each turn
|
|
203
|
+
# (0 = disable recall, keeping only the session-start index injection).
|
|
204
|
+
recall_k: int = 5
|
|
205
|
+
# Semantic recall (task 06-08): off by default keeps the lexical behavior
|
|
206
|
+
# byte-identical. When on, recall ranks by embedding cosine similarity and
|
|
207
|
+
# fails open to lexical when the backend is unavailable. ``embedding_backend``
|
|
208
|
+
# is ``openai`` (reuses the openai client / a configurable embeddings
|
|
209
|
+
# endpoint) or ``local`` (fastembed, the ``[embeddings]`` extra). An empty
|
|
210
|
+
# model resolves to the backend default. The openai base_url / api_key fall
|
|
211
|
+
# back to the session provider's when left empty. All restart-required.
|
|
212
|
+
semantic_recall: bool = False
|
|
213
|
+
embedding_backend: str = "openai"
|
|
214
|
+
embedding_model: str = ""
|
|
215
|
+
embedding_base_url: str = ""
|
|
216
|
+
embedding_api_key: str = ""
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@dataclass(slots=True)
|
|
220
|
+
class CostConfig:
|
|
221
|
+
# Per-model price overrides keyed by model id. Each entry is a
|
|
222
|
+
# ``{"input": <usd-per-million>, "output": <usd-per-million>}`` dict that
|
|
223
|
+
# overrides/extends the built-in Claude default prices in token_tracker.py.
|
|
224
|
+
prices: dict[str, dict[str, float]] = field(default_factory=dict)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@dataclass(slots=True)
|
|
228
|
+
class RetryConfig:
|
|
229
|
+
# Mirrors src/core/retry.py:RetryPolicy (same field names + defaults). The
|
|
230
|
+
# app layer owns LLM retries exclusively (SDK clients use max_retries=0).
|
|
231
|
+
enabled: bool = True
|
|
232
|
+
max_attempts: int = 3 # total attempts (incl. first), <=1 disables retries
|
|
233
|
+
base_delay_sec: float = 1.0
|
|
234
|
+
max_delay_sec: float = 30.0
|
|
235
|
+
multiplier: float = 2.0
|
|
236
|
+
jitter: bool = True
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@dataclass(slots=True)
|
|
240
|
+
class SkillsConfig:
|
|
241
|
+
# Experiential skill generation (task 06-01-experiential-skill-gen): after a
|
|
242
|
+
# complex multi-turn task the agent auto-drafts a reusable skill into a
|
|
243
|
+
# pending area for the user to promote with /skill keep.
|
|
244
|
+
auto_generate: bool = True
|
|
245
|
+
# Double-AND trigger thresholds (cumulative since session start / last draft).
|
|
246
|
+
min_tool_calls: int = 5
|
|
247
|
+
min_user_replies: int = 3
|
|
248
|
+
# Soft cap on pending drafts (oldest pruned beyond this; <=0 disables).
|
|
249
|
+
max_pending: int = 10
|
|
250
|
+
# Generated-skills root override. Empty -> per-project default under
|
|
251
|
+
# ~/.bareagent/projects/<slug>/skills/.
|
|
252
|
+
dir: str = ""
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@dataclass(slots=True)
|
|
256
|
+
class GoalConfig:
|
|
257
|
+
# Goal completion loop (task 06-06-goal-completion-loop): /goal <condition>
|
|
258
|
+
# drives turns until an isolated evaluator judges the condition met.
|
|
259
|
+
# Turn-budget safety valve; an inline `--max-turns N` overrides per invocation.
|
|
260
|
+
max_turns: int = DEFAULT_MAX_TURNS
|
|
261
|
+
# Optional cheaper model for the per-turn evaluator. Empty -> reuse the
|
|
262
|
+
# session provider/model (no extra client, works for any provider).
|
|
263
|
+
evaluator_model: str = ""
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
@dataclass(slots=True)
|
|
267
|
+
class WorkflowConfig:
|
|
268
|
+
# Deterministic workflow orchestration (task
|
|
269
|
+
# 06-06-workflow-deterministic-orchestration): the LLM authors a static DAG of
|
|
270
|
+
# subagent nodes via the main-loop-only ``workflow`` tool; independent nodes
|
|
271
|
+
# run concurrently. ``enabled=false`` short-circuits the whole feature (the
|
|
272
|
+
# tool is never installed). Honors ``BAREAGENT_WORKFLOW_ENABLED``.
|
|
273
|
+
enabled: bool = True
|
|
274
|
+
# Max nodes that may run concurrently (each node is a full subagent).
|
|
275
|
+
max_concurrency: int = DEFAULT_MAX_CONCURRENCY
|
|
276
|
+
# Ceiling on declared nodes per workflow; guards the thread pool against an
|
|
277
|
+
# oversized DAG.
|
|
278
|
+
max_nodes: int = DEFAULT_MAX_NODES
|
|
279
|
+
# Default token ceiling per run when the ``workflow`` call omits
|
|
280
|
+
# ``token_budget``; 0 = unlimited. The per-call field overrides this. Honors
|
|
281
|
+
# ``BAREAGENT_WORKFLOW_DEFAULT_TOKEN_BUDGET``.
|
|
282
|
+
default_token_budget: int = 0
|
|
283
|
+
# FIFO cap on retained run records (panel history + resume source); evicting
|
|
284
|
+
# the oldest beyond this. Honors ``BAREAGENT_WORKFLOW_MAX_RUNS``.
|
|
285
|
+
max_runs: int = DEFAULT_MAX_RUNS
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
@dataclass(slots=True)
|
|
289
|
+
class TeamConfig:
|
|
290
|
+
# Multi-agent teammate coordination (task 06-06-team-subsystem-completion).
|
|
291
|
+
# ``poll_interval`` is how long an idle teammate daemon waits between
|
|
292
|
+
# task-scan wakeups (it also wakes immediately on incoming mail via the
|
|
293
|
+
# mailbox condition variable). ``response_timeout`` is how long a blocking
|
|
294
|
+
# ``team_send`` waits for a teammate's reply before returning a timeout note.
|
|
295
|
+
# ``memory_enabled`` (task 06-08-team-stateful-memory) makes a teammate carry
|
|
296
|
+
# conversational memory across *requests* (a per-teammate Compactor is injected
|
|
297
|
+
# to bound growth); off restores the old per-request stateless behavior.
|
|
298
|
+
# All three are baked into spawned teammates / send calls at boot ->
|
|
299
|
+
# restart-required.
|
|
300
|
+
poll_interval: float = 1.0
|
|
301
|
+
response_timeout: float = 60.0
|
|
302
|
+
memory_enabled: bool = True
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
@dataclass(slots=True)
|
|
306
|
+
class Config:
|
|
307
|
+
provider: ProviderConfig
|
|
308
|
+
permission: PermissionConfig
|
|
309
|
+
ui: UIConfig
|
|
310
|
+
subagent: SubagentConfig
|
|
311
|
+
thinking: ThinkingConfig
|
|
312
|
+
debug: DebugConfig
|
|
313
|
+
tracing: TracingConfig
|
|
314
|
+
path: Path
|
|
315
|
+
mcp: MCPConfig
|
|
316
|
+
lsp: LSPConfig
|
|
317
|
+
# Defaulted so existing Config(...) constructions (tests, fixtures) keep
|
|
318
|
+
# working without passing memory explicitly.
|
|
319
|
+
memory: MemoryConfig = field(default_factory=MemoryConfig)
|
|
320
|
+
cost: CostConfig = field(default_factory=CostConfig)
|
|
321
|
+
hooks: HooksConfig = field(default_factory=HooksConfig)
|
|
322
|
+
retry: RetryConfig = field(default_factory=RetryConfig)
|
|
323
|
+
# Prompt caching (Anthropic explicit cache_control breakpoints). Defined in
|
|
324
|
+
# provider.base so the factory/provider share one type, mirroring thinking.
|
|
325
|
+
cache: CacheConfig = field(default_factory=CacheConfig)
|
|
326
|
+
skills: SkillsConfig = field(default_factory=SkillsConfig)
|
|
327
|
+
goal: GoalConfig = field(default_factory=GoalConfig)
|
|
328
|
+
workflow: WorkflowConfig = field(default_factory=WorkflowConfig)
|
|
329
|
+
team: TeamConfig = field(default_factory=TeamConfig)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
# Dotted config paths that ``/reload`` can hot-apply to live runtime objects.
|
|
333
|
+
# Anything else that changes on disk is reported as "requires restart" because
|
|
334
|
+
# it was baked into a manager/client/provider at boot (see CLAUDE.md ROADMAP 4.3).
|
|
335
|
+
_HOT_RELOAD_PATHS = frozenset(
|
|
336
|
+
{
|
|
337
|
+
"ui.theme",
|
|
338
|
+
"permission.mode",
|
|
339
|
+
"permission.allow",
|
|
340
|
+
"permission.deny",
|
|
341
|
+
}
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
@dataclass(slots=True)
|
|
346
|
+
class ConfigChange:
|
|
347
|
+
"""A single changed config leaf, identified by its dotted path."""
|
|
348
|
+
|
|
349
|
+
path: str # dotted, e.g. "ui.theme"
|
|
350
|
+
old: Any
|
|
351
|
+
new: Any
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
@dataclass(slots=True)
|
|
355
|
+
class ReloadReport:
|
|
356
|
+
"""Classification of a config diff into hot (applied) vs restart-required."""
|
|
357
|
+
|
|
358
|
+
hot: list[ConfigChange] # hot-reloadable and will be applied
|
|
359
|
+
restart: list[ConfigChange] # changed but only reported (needs restart)
|
|
360
|
+
|
|
361
|
+
@property
|
|
362
|
+
def changed(self) -> bool:
|
|
363
|
+
return bool(self.hot or self.restart)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _flatten_config(data: dict[str, Any]) -> dict[str, Any]:
|
|
367
|
+
"""Flatten a top-level ``asdict(Config)`` mapping into dotted-path leaves.
|
|
368
|
+
|
|
369
|
+
Each top-level Config field (provider/permission/ui/...) is a nested
|
|
370
|
+
dataclass that ``asdict`` rendered as a dict, so we descend exactly one
|
|
371
|
+
level to produce ``section.field`` leaves. Whatever sits at the second level
|
|
372
|
+
(scalar, ``list`` like ``permission.allow``, or ``dict`` like ``cost.prices``)
|
|
373
|
+
is a single leaf compared wholesale — order changes in a list count as a
|
|
374
|
+
change. ``path`` is a scalar and stays a top-level leaf.
|
|
375
|
+
"""
|
|
376
|
+
leaves: dict[str, Any] = {}
|
|
377
|
+
for key, value in data.items():
|
|
378
|
+
if isinstance(value, dict):
|
|
379
|
+
for sub_key, sub_value in value.items():
|
|
380
|
+
leaves[f"{key}.{sub_key}"] = sub_value
|
|
381
|
+
else:
|
|
382
|
+
leaves[key] = value
|
|
383
|
+
return leaves
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _diff_config_for_reload(old: Config, new: Config) -> ReloadReport:
|
|
387
|
+
"""Diff two configs and classify each changed leaf as hot vs restart.
|
|
388
|
+
|
|
389
|
+
Pure function (no side effects) so it can be unit tested. The ``path`` field
|
|
390
|
+
(a resolved filesystem path, not a config knob) is skipped entirely.
|
|
391
|
+
"""
|
|
392
|
+
old_flat = _flatten_config(asdict(old))
|
|
393
|
+
new_flat = _flatten_config(asdict(new))
|
|
394
|
+
|
|
395
|
+
hot: list[ConfigChange] = []
|
|
396
|
+
restart: list[ConfigChange] = []
|
|
397
|
+
for dotted in sorted(set(old_flat) | set(new_flat)):
|
|
398
|
+
if dotted == "path":
|
|
399
|
+
continue
|
|
400
|
+
old_value = old_flat.get(dotted)
|
|
401
|
+
new_value = new_flat.get(dotted)
|
|
402
|
+
if old_value == new_value:
|
|
403
|
+
continue
|
|
404
|
+
change = ConfigChange(path=dotted, old=old_value, new=new_value)
|
|
405
|
+
if dotted in _HOT_RELOAD_PATHS:
|
|
406
|
+
hot.append(change)
|
|
407
|
+
else:
|
|
408
|
+
restart.append(change)
|
|
409
|
+
return ReloadReport(hot=hot, restart=restart)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _config_mtimes(config: Config) -> dict[str, float]:
|
|
413
|
+
"""Best-effort mtimes of config.toml + its .local sibling.
|
|
414
|
+
|
|
415
|
+
Missing files are skipped (so creating/deleting the local override is itself
|
|
416
|
+
a detectable change). Used by the passive on-prompt change detector.
|
|
417
|
+
"""
|
|
418
|
+
main_path = config.path
|
|
419
|
+
local_path = local_config_path(main_path)
|
|
420
|
+
mtimes: dict[str, float] = {}
|
|
421
|
+
for path in (main_path, local_path):
|
|
422
|
+
try:
|
|
423
|
+
mtimes[str(path)] = os.stat(path).st_mtime
|
|
424
|
+
except OSError:
|
|
425
|
+
continue
|
|
426
|
+
return mtimes
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _add_common_arguments(parser: argparse.ArgumentParser) -> None:
|
|
430
|
+
parser.add_argument("--provider", help="Override the configured provider name.")
|
|
431
|
+
parser.add_argument("--model", help="Override the configured model name.")
|
|
432
|
+
parser.add_argument(
|
|
433
|
+
"--config",
|
|
434
|
+
type=Path,
|
|
435
|
+
help=(
|
|
436
|
+
"Path to the TOML config file. Defaults to BAREAGENT_CONFIG or the bundled config.toml."
|
|
437
|
+
),
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|
442
|
+
parser = argparse.ArgumentParser(prog="bareagent")
|
|
443
|
+
# Top-level flags stay usable with no subcommand so the existing
|
|
444
|
+
# ``bareagent --provider ... --model ...`` REPL invocation is unchanged.
|
|
445
|
+
_add_common_arguments(parser)
|
|
446
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
447
|
+
init_parser = subparsers.add_parser(
|
|
448
|
+
"init",
|
|
449
|
+
help="Interactively configure a provider and write config.local.toml.",
|
|
450
|
+
)
|
|
451
|
+
# Allow ``bareagent init --config <path>`` to target a specific config file.
|
|
452
|
+
_add_common_arguments(init_parser)
|
|
453
|
+
return parser.parse_args(argv)
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _deep_merge(base: dict, override: dict) -> dict:
|
|
457
|
+
"""Recursively merge *override* into *base* (returns a new dict)."""
|
|
458
|
+
merged = base.copy()
|
|
459
|
+
for key, value in override.items():
|
|
460
|
+
if key in merged and isinstance(merged[key], dict) and isinstance(value, dict):
|
|
461
|
+
merged[key] = _deep_merge(merged[key], value)
|
|
462
|
+
else:
|
|
463
|
+
merged[key] = value
|
|
464
|
+
return merged
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _read_config_file(config_path: Path) -> dict:
|
|
468
|
+
with config_path.open("rb") as file:
|
|
469
|
+
base = tomllib.load(file)
|
|
470
|
+
local_path = local_config_path(config_path)
|
|
471
|
+
if local_path.is_file():
|
|
472
|
+
with local_path.open("rb") as file:
|
|
473
|
+
local = tomllib.load(file)
|
|
474
|
+
return _deep_merge(base, local)
|
|
475
|
+
return base
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _resolve_string(
|
|
479
|
+
file_value: str,
|
|
480
|
+
env_name: str,
|
|
481
|
+
cli_value: str | None = None,
|
|
482
|
+
) -> str:
|
|
483
|
+
if cli_value is not None:
|
|
484
|
+
return cli_value
|
|
485
|
+
return os.getenv(env_name, file_value)
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _resolve_bool(file_value: bool, env_name: str) -> bool:
|
|
489
|
+
raw_value = os.getenv(env_name)
|
|
490
|
+
if raw_value is None:
|
|
491
|
+
return file_value
|
|
492
|
+
|
|
493
|
+
normalized = raw_value.strip().lower()
|
|
494
|
+
if normalized in {"1", "true", "yes", "on"}:
|
|
495
|
+
return True
|
|
496
|
+
if normalized in {"0", "false", "no", "off"}:
|
|
497
|
+
return False
|
|
498
|
+
raise ValueError(f"{env_name} must be a boolean value, got: {raw_value}")
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _resolve_int(file_value: int, env_name: str) -> int:
|
|
502
|
+
raw_value = os.getenv(env_name)
|
|
503
|
+
if raw_value is None:
|
|
504
|
+
return file_value
|
|
505
|
+
return int(raw_value)
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _resolve_optional_string(file_value: str | None, env_name: str) -> str | None:
|
|
509
|
+
raw_value = os.getenv(env_name)
|
|
510
|
+
value = raw_value if raw_value is not None else file_value
|
|
511
|
+
if value in {None, ""}:
|
|
512
|
+
return None
|
|
513
|
+
return value
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _validate_mode(name: str, value: str, allowed: AbstractSet[str]) -> str:
|
|
517
|
+
if value not in allowed:
|
|
518
|
+
allowed_values = ", ".join(sorted(allowed))
|
|
519
|
+
raise ValueError(f"{name} must be one of: {allowed_values}")
|
|
520
|
+
return value
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _default_api_key_env(provider_name: str) -> str:
|
|
524
|
+
return DEFAULT_API_KEY_ENV_BY_PROVIDER.get(provider_name.lower(), "ANTHROPIC_API_KEY")
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def resolve_config_path(config_path: Path | None) -> Path:
|
|
528
|
+
if config_path is not None:
|
|
529
|
+
return config_path.expanduser()
|
|
530
|
+
|
|
531
|
+
env_path = os.getenv("BAREAGENT_CONFIG")
|
|
532
|
+
if env_path:
|
|
533
|
+
return Path(env_path).expanduser()
|
|
534
|
+
|
|
535
|
+
return DEFAULT_CONFIG_PATH
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def _has_usable_key(provider: ProviderConfig) -> bool:
|
|
539
|
+
"""Return whether *provider* can resolve an API key without the wizard.
|
|
540
|
+
|
|
541
|
+
Mirrors :func:`bareagent.provider.factory._resolve_api_key`: an explicit
|
|
542
|
+
plaintext ``api_key`` wins, an ``sk-`` prefixed ``api_key_env`` is itself
|
|
543
|
+
the key, and otherwise the named environment variable must be populated.
|
|
544
|
+
"""
|
|
545
|
+
if provider.api_key:
|
|
546
|
+
return True
|
|
547
|
+
api_key_env = provider.api_key_env or ""
|
|
548
|
+
if api_key_env.startswith("sk-"):
|
|
549
|
+
return True
|
|
550
|
+
return bool(api_key_env and os.getenv(api_key_env))
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def _parse_cost_config(cost_raw: dict) -> CostConfig:
|
|
554
|
+
"""Parse the ``[cost]`` / ``[cost.prices]`` config section.
|
|
555
|
+
|
|
556
|
+
Each ``[cost.prices."<model-id>"]`` table is coerced into a
|
|
557
|
+
``{"input": float, "output": float}`` dict (USD per million tokens).
|
|
558
|
+
Malformed or incomplete entries are skipped so a bad override never crashes
|
|
559
|
+
boot — the model simply shows token counts without a ``$`` estimate.
|
|
560
|
+
"""
|
|
561
|
+
prices_raw = cost_raw.get("prices", {})
|
|
562
|
+
prices: dict[str, dict[str, float]] = {}
|
|
563
|
+
if isinstance(prices_raw, dict):
|
|
564
|
+
for model, entry in prices_raw.items():
|
|
565
|
+
if not isinstance(entry, dict):
|
|
566
|
+
continue
|
|
567
|
+
try:
|
|
568
|
+
prices[str(model)] = {
|
|
569
|
+
"input": float(entry["input"]),
|
|
570
|
+
"output": float(entry["output"]),
|
|
571
|
+
}
|
|
572
|
+
except (KeyError, TypeError, ValueError):
|
|
573
|
+
continue
|
|
574
|
+
return CostConfig(prices=prices)
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def _parse_retry_config(retry_raw: dict) -> RetryConfig:
|
|
578
|
+
"""Parse the ``[retry]`` config section.
|
|
579
|
+
|
|
580
|
+
Each field is parsed defensively — a malformed value falls back to the
|
|
581
|
+
default rather than crashing boot (mirrors ``_parse_cost_config``).
|
|
582
|
+
``enabled`` / ``max_attempts`` honor env overrides
|
|
583
|
+
(``BAREAGENT_RETRY_ENABLED`` / ``BAREAGENT_RETRY_MAX_ATTEMPTS``); the
|
|
584
|
+
remaining fields are config-only.
|
|
585
|
+
"""
|
|
586
|
+
defaults = RetryConfig()
|
|
587
|
+
try:
|
|
588
|
+
enabled = _resolve_bool(
|
|
589
|
+
bool(retry_raw.get("enabled", defaults.enabled)),
|
|
590
|
+
"BAREAGENT_RETRY_ENABLED",
|
|
591
|
+
)
|
|
592
|
+
except (TypeError, ValueError):
|
|
593
|
+
enabled = defaults.enabled
|
|
594
|
+
try:
|
|
595
|
+
max_attempts = _resolve_int(
|
|
596
|
+
int(retry_raw.get("max_attempts", defaults.max_attempts)),
|
|
597
|
+
"BAREAGENT_RETRY_MAX_ATTEMPTS",
|
|
598
|
+
)
|
|
599
|
+
except (TypeError, ValueError):
|
|
600
|
+
max_attempts = defaults.max_attempts
|
|
601
|
+
try:
|
|
602
|
+
base_delay_sec = float(retry_raw.get("base_delay_sec", defaults.base_delay_sec))
|
|
603
|
+
except (TypeError, ValueError):
|
|
604
|
+
base_delay_sec = defaults.base_delay_sec
|
|
605
|
+
try:
|
|
606
|
+
max_delay_sec = float(retry_raw.get("max_delay_sec", defaults.max_delay_sec))
|
|
607
|
+
except (TypeError, ValueError):
|
|
608
|
+
max_delay_sec = defaults.max_delay_sec
|
|
609
|
+
try:
|
|
610
|
+
multiplier = float(retry_raw.get("multiplier", defaults.multiplier))
|
|
611
|
+
except (TypeError, ValueError):
|
|
612
|
+
multiplier = defaults.multiplier
|
|
613
|
+
try:
|
|
614
|
+
jitter = bool(retry_raw.get("jitter", defaults.jitter))
|
|
615
|
+
except (TypeError, ValueError):
|
|
616
|
+
jitter = defaults.jitter
|
|
617
|
+
return RetryConfig(
|
|
618
|
+
enabled=enabled,
|
|
619
|
+
max_attempts=max_attempts,
|
|
620
|
+
base_delay_sec=base_delay_sec,
|
|
621
|
+
max_delay_sec=max_delay_sec,
|
|
622
|
+
multiplier=multiplier,
|
|
623
|
+
jitter=jitter,
|
|
624
|
+
)
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def _build_retry_policy(retry_config: RetryConfig) -> RetryPolicy:
|
|
628
|
+
return RetryPolicy(
|
|
629
|
+
enabled=retry_config.enabled,
|
|
630
|
+
max_attempts=retry_config.max_attempts,
|
|
631
|
+
base_delay_sec=retry_config.base_delay_sec,
|
|
632
|
+
max_delay_sec=retry_config.max_delay_sec,
|
|
633
|
+
multiplier=retry_config.multiplier,
|
|
634
|
+
jitter=retry_config.jitter,
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def _parse_cache_config(cache_raw: dict) -> CacheConfig:
|
|
639
|
+
"""Parse the ``[cache]`` config section (defensive, never crashes boot).
|
|
640
|
+
|
|
641
|
+
``enabled`` honors the ``BAREAGENT_CACHE_ENABLED`` env override (mirrors
|
|
642
|
+
``[retry]``); ``ttl`` is config-only and falls back to ``"5m"`` for any
|
|
643
|
+
value outside ``{"5m", "1h"}``. Only the Anthropic provider acts on this.
|
|
644
|
+
"""
|
|
645
|
+
defaults = CacheConfig()
|
|
646
|
+
try:
|
|
647
|
+
enabled = _resolve_bool(
|
|
648
|
+
bool(cache_raw.get("enabled", defaults.enabled)),
|
|
649
|
+
"BAREAGENT_CACHE_ENABLED",
|
|
650
|
+
)
|
|
651
|
+
except (TypeError, ValueError):
|
|
652
|
+
enabled = defaults.enabled
|
|
653
|
+
ttl_raw = str(cache_raw.get("ttl", defaults.ttl)).strip().lower()
|
|
654
|
+
ttl = cast(Literal["5m", "1h"], ttl_raw) if ttl_raw in VALID_CACHE_TTLS else defaults.ttl
|
|
655
|
+
return CacheConfig(enabled=enabled, ttl=ttl)
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def _parse_skills_config(skills_raw: dict) -> SkillsConfig:
|
|
659
|
+
"""Parse the ``[skills]`` config section (defensive, never crashes boot).
|
|
660
|
+
|
|
661
|
+
``auto_generate`` honors ``BAREAGENT_SKILLS_AUTO_GENERATE`` (mirrors
|
|
662
|
+
``[retry]``/``[cache]``); the rest are config-only and fall back per field.
|
|
663
|
+
Note ``BAREAGENT_SKILLS_DIR`` is a *separate* knob for the repo canon dir
|
|
664
|
+
(``resolve_skills_dir``), so the generated-root override here is config-only.
|
|
665
|
+
"""
|
|
666
|
+
defaults = SkillsConfig()
|
|
667
|
+
try:
|
|
668
|
+
auto_generate = _resolve_bool(
|
|
669
|
+
bool(skills_raw.get("auto_generate", defaults.auto_generate)),
|
|
670
|
+
"BAREAGENT_SKILLS_AUTO_GENERATE",
|
|
671
|
+
)
|
|
672
|
+
except (TypeError, ValueError):
|
|
673
|
+
auto_generate = defaults.auto_generate
|
|
674
|
+
|
|
675
|
+
def _int_field(key: str, fallback: int) -> int:
|
|
676
|
+
try:
|
|
677
|
+
return int(skills_raw.get(key, fallback))
|
|
678
|
+
except (TypeError, ValueError):
|
|
679
|
+
return fallback
|
|
680
|
+
|
|
681
|
+
return SkillsConfig(
|
|
682
|
+
auto_generate=auto_generate,
|
|
683
|
+
min_tool_calls=_int_field("min_tool_calls", defaults.min_tool_calls),
|
|
684
|
+
min_user_replies=_int_field("min_user_replies", defaults.min_user_replies),
|
|
685
|
+
max_pending=_int_field("max_pending", defaults.max_pending),
|
|
686
|
+
dir=str(skills_raw.get("dir", defaults.dir)),
|
|
687
|
+
)
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def _build_skillgen_config(skills: SkillsConfig) -> SkillGenConfig:
|
|
691
|
+
"""Adapt the user-facing ``SkillsConfig`` to the pure ``SkillGenConfig``."""
|
|
692
|
+
return SkillGenConfig(
|
|
693
|
+
enabled=skills.auto_generate,
|
|
694
|
+
min_tool_calls=skills.min_tool_calls,
|
|
695
|
+
min_user_replies=skills.min_user_replies,
|
|
696
|
+
)
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def _parse_goal_config(goal_raw: dict) -> GoalConfig:
|
|
700
|
+
"""Parse the ``[goal]`` config section (defensive, never crashes boot).
|
|
701
|
+
|
|
702
|
+
``max_turns`` honors ``BAREAGENT_GOAL_MAX_TURNS`` (mirrors ``[retry]``);
|
|
703
|
+
``evaluator_model`` is config-only. A malformed value falls back to the
|
|
704
|
+
default per field.
|
|
705
|
+
"""
|
|
706
|
+
defaults = GoalConfig()
|
|
707
|
+
try:
|
|
708
|
+
max_turns = _resolve_int(
|
|
709
|
+
int(goal_raw.get("max_turns", defaults.max_turns)),
|
|
710
|
+
"BAREAGENT_GOAL_MAX_TURNS",
|
|
711
|
+
)
|
|
712
|
+
except (TypeError, ValueError):
|
|
713
|
+
max_turns = defaults.max_turns
|
|
714
|
+
if max_turns < 1:
|
|
715
|
+
max_turns = defaults.max_turns
|
|
716
|
+
return GoalConfig(
|
|
717
|
+
max_turns=max_turns,
|
|
718
|
+
evaluator_model=str(goal_raw.get("evaluator_model", defaults.evaluator_model)).strip(),
|
|
719
|
+
)
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def _parse_workflow_config(workflow_raw: dict) -> WorkflowConfig:
|
|
723
|
+
"""Parse the ``[workflow]`` config section (defensive, never crashes boot).
|
|
724
|
+
|
|
725
|
+
``enabled`` honors ``BAREAGENT_WORKFLOW_ENABLED``; the integer caps are
|
|
726
|
+
config-only and fall back to their default when missing / malformed / < 1.
|
|
727
|
+
"""
|
|
728
|
+
defaults = WorkflowConfig()
|
|
729
|
+
try:
|
|
730
|
+
enabled = _resolve_bool(
|
|
731
|
+
bool(workflow_raw.get("enabled", defaults.enabled)),
|
|
732
|
+
"BAREAGENT_WORKFLOW_ENABLED",
|
|
733
|
+
)
|
|
734
|
+
except (TypeError, ValueError):
|
|
735
|
+
enabled = defaults.enabled
|
|
736
|
+
|
|
737
|
+
def _positive_int(key: str, fallback: int) -> int:
|
|
738
|
+
try:
|
|
739
|
+
value = int(workflow_raw.get(key, fallback))
|
|
740
|
+
except (TypeError, ValueError):
|
|
741
|
+
return fallback
|
|
742
|
+
return value if value >= 1 else fallback
|
|
743
|
+
|
|
744
|
+
# default_token_budget allows 0 (unlimited), so it is parsed as non-negative
|
|
745
|
+
# rather than positive; env override mirrors the enabled knob.
|
|
746
|
+
try:
|
|
747
|
+
default_budget = _resolve_int(
|
|
748
|
+
int(workflow_raw.get("default_token_budget", defaults.default_token_budget)),
|
|
749
|
+
"BAREAGENT_WORKFLOW_DEFAULT_TOKEN_BUDGET",
|
|
750
|
+
)
|
|
751
|
+
except (TypeError, ValueError):
|
|
752
|
+
default_budget = defaults.default_token_budget
|
|
753
|
+
if default_budget < 0:
|
|
754
|
+
default_budget = defaults.default_token_budget
|
|
755
|
+
|
|
756
|
+
try:
|
|
757
|
+
max_runs = _resolve_int(
|
|
758
|
+
int(workflow_raw.get("max_runs", defaults.max_runs)),
|
|
759
|
+
"BAREAGENT_WORKFLOW_MAX_RUNS",
|
|
760
|
+
)
|
|
761
|
+
except (TypeError, ValueError):
|
|
762
|
+
max_runs = defaults.max_runs
|
|
763
|
+
if max_runs < 1:
|
|
764
|
+
max_runs = defaults.max_runs
|
|
765
|
+
|
|
766
|
+
return WorkflowConfig(
|
|
767
|
+
enabled=enabled,
|
|
768
|
+
max_concurrency=_positive_int("max_concurrency", defaults.max_concurrency),
|
|
769
|
+
max_nodes=_positive_int("max_nodes", defaults.max_nodes),
|
|
770
|
+
default_token_budget=default_budget,
|
|
771
|
+
max_runs=max_runs,
|
|
772
|
+
)
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
def _parse_team_config(team_raw: dict) -> TeamConfig:
|
|
776
|
+
"""Parse the ``[team]`` config section (defensive, never crashes boot).
|
|
777
|
+
|
|
778
|
+
``poll_interval`` / ``response_timeout`` are config-only positive floats; a
|
|
779
|
+
missing / malformed / <= 0 value falls back to its default. ``memory_enabled``
|
|
780
|
+
honors the ``BAREAGENT_TEAM_MEMORY_ENABLED`` env override (mirrors retry /
|
|
781
|
+
cache / workflow ``enabled`` knobs).
|
|
782
|
+
"""
|
|
783
|
+
defaults = TeamConfig()
|
|
784
|
+
|
|
785
|
+
def _positive_float(key: str, fallback: float) -> float:
|
|
786
|
+
try:
|
|
787
|
+
value = float(team_raw.get(key, fallback))
|
|
788
|
+
except (TypeError, ValueError):
|
|
789
|
+
return fallback
|
|
790
|
+
return value if value > 0 else fallback
|
|
791
|
+
|
|
792
|
+
try:
|
|
793
|
+
memory_enabled = _resolve_bool(
|
|
794
|
+
bool(team_raw.get("memory_enabled", defaults.memory_enabled)),
|
|
795
|
+
"BAREAGENT_TEAM_MEMORY_ENABLED",
|
|
796
|
+
)
|
|
797
|
+
except (TypeError, ValueError):
|
|
798
|
+
memory_enabled = defaults.memory_enabled
|
|
799
|
+
|
|
800
|
+
return TeamConfig(
|
|
801
|
+
poll_interval=_positive_float("poll_interval", defaults.poll_interval),
|
|
802
|
+
response_timeout=_positive_float("response_timeout", defaults.response_timeout),
|
|
803
|
+
memory_enabled=memory_enabled,
|
|
804
|
+
)
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
def _build_goal_provider(
|
|
808
|
+
config: Config,
|
|
809
|
+
session_provider: BaseLLMProvider,
|
|
810
|
+
) -> BaseLLMProvider:
|
|
811
|
+
"""Provider for the goal evaluator: a cheaper model if configured, else reuse.
|
|
812
|
+
|
|
813
|
+
``[goal] evaluator_model`` empty -> reuse the session provider (no extra
|
|
814
|
+
client). Otherwise build a sibling provider with that model via the factory
|
|
815
|
+
(same provider family / credentials). On any build failure, warn and fall
|
|
816
|
+
back to the session provider so a bad model id never blocks ``/goal``.
|
|
817
|
+
"""
|
|
818
|
+
model = config.goal.evaluator_model.strip()
|
|
819
|
+
if not model:
|
|
820
|
+
return session_provider
|
|
821
|
+
try:
|
|
822
|
+
eval_config = replace(config, provider=replace(config.provider, model=model))
|
|
823
|
+
return create_provider(eval_config)
|
|
824
|
+
except Exception as exc: # noqa: BLE001 - never block /goal on evaluator setup
|
|
825
|
+
_log.warning(
|
|
826
|
+
"Goal evaluator provider build failed (%s); reusing session provider.",
|
|
827
|
+
exc,
|
|
828
|
+
)
|
|
829
|
+
return session_provider
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
def load_config(
|
|
833
|
+
config_path: Path,
|
|
834
|
+
*,
|
|
835
|
+
provider_override: str | None = None,
|
|
836
|
+
model_override: str | None = None,
|
|
837
|
+
) -> Config:
|
|
838
|
+
raw_config = _read_config_file(config_path)
|
|
839
|
+
provider_raw = raw_config.get("provider", {})
|
|
840
|
+
permission_raw = raw_config.get("permission", {})
|
|
841
|
+
ui_raw = raw_config.get("ui", {})
|
|
842
|
+
subagent_raw = raw_config.get("subagent", {})
|
|
843
|
+
thinking_raw = raw_config.get("thinking", {})
|
|
844
|
+
debug_raw = raw_config.get("debug", {})
|
|
845
|
+
tracing_raw = raw_config.get("tracing", {})
|
|
846
|
+
allow_rules, deny_rules = parse_permission_rules(raw_config)
|
|
847
|
+
configured_provider_name = str(provider_raw.get("name", "anthropic"))
|
|
848
|
+
provider_name = _resolve_string(
|
|
849
|
+
configured_provider_name,
|
|
850
|
+
"BAREAGENT_PROVIDER",
|
|
851
|
+
provider_override,
|
|
852
|
+
)
|
|
853
|
+
default_api_key_env = _default_api_key_env(provider_name)
|
|
854
|
+
configured_api_key_env = provider_raw.get("api_key_env")
|
|
855
|
+
api_key_env_default = (
|
|
856
|
+
configured_api_key_env
|
|
857
|
+
if configured_api_key_env and provider_name == configured_provider_name
|
|
858
|
+
else default_api_key_env
|
|
859
|
+
)
|
|
860
|
+
|
|
861
|
+
provider = ProviderConfig(
|
|
862
|
+
name=provider_name,
|
|
863
|
+
model=_resolve_string(
|
|
864
|
+
provider_raw.get("model", "claude-sonnet-4-20250514"),
|
|
865
|
+
"BAREAGENT_MODEL",
|
|
866
|
+
model_override,
|
|
867
|
+
),
|
|
868
|
+
api_key_env=_resolve_string(
|
|
869
|
+
api_key_env_default,
|
|
870
|
+
"BAREAGENT_API_KEY_ENV",
|
|
871
|
+
),
|
|
872
|
+
api_key=_resolve_optional_string(
|
|
873
|
+
provider_raw.get("api_key"),
|
|
874
|
+
"BAREAGENT_API_KEY",
|
|
875
|
+
),
|
|
876
|
+
base_url=_resolve_optional_string(
|
|
877
|
+
provider_raw.get("base_url"),
|
|
878
|
+
"BAREAGENT_BASE_URL",
|
|
879
|
+
),
|
|
880
|
+
wire_api=_resolve_optional_string(
|
|
881
|
+
provider_raw.get("wire_api"),
|
|
882
|
+
"BAREAGENT_WIRE_API",
|
|
883
|
+
),
|
|
884
|
+
)
|
|
885
|
+
permission = PermissionConfig(
|
|
886
|
+
mode=_validate_mode(
|
|
887
|
+
"permission.mode",
|
|
888
|
+
_resolve_string(
|
|
889
|
+
permission_raw.get("mode", "default"),
|
|
890
|
+
"BAREAGENT_PERMISSION_MODE",
|
|
891
|
+
),
|
|
892
|
+
VALID_PERMISSION_MODES,
|
|
893
|
+
),
|
|
894
|
+
allow=allow_rules,
|
|
895
|
+
deny=deny_rules,
|
|
896
|
+
)
|
|
897
|
+
ui = UIConfig(
|
|
898
|
+
stream=_resolve_bool(ui_raw.get("stream", True), "BAREAGENT_UI_STREAM"),
|
|
899
|
+
theme=_resolve_string(ui_raw.get("theme", "dark"), "BAREAGENT_UI_THEME"),
|
|
900
|
+
)
|
|
901
|
+
try:
|
|
902
|
+
subagent_max_resumable = int(subagent_raw.get("max_resumable", 20))
|
|
903
|
+
except (TypeError, ValueError):
|
|
904
|
+
subagent_max_resumable = 20
|
|
905
|
+
if subagent_max_resumable < 1:
|
|
906
|
+
subagent_max_resumable = 20
|
|
907
|
+
subagent = SubagentConfig(
|
|
908
|
+
max_depth=_resolve_int(
|
|
909
|
+
int(subagent_raw.get("max_depth", 3)),
|
|
910
|
+
"BAREAGENT_SUBAGENT_MAX_DEPTH",
|
|
911
|
+
),
|
|
912
|
+
default_type=_validate_mode(
|
|
913
|
+
"subagent.default_type",
|
|
914
|
+
_resolve_string(
|
|
915
|
+
str(subagent_raw.get("default_type", DEFAULT_AGENT_TYPE)),
|
|
916
|
+
"BAREAGENT_SUBAGENT_DEFAULT_TYPE",
|
|
917
|
+
),
|
|
918
|
+
VALID_SUBAGENT_TYPES,
|
|
919
|
+
),
|
|
920
|
+
max_resumable=subagent_max_resumable,
|
|
921
|
+
)
|
|
922
|
+
thinking = ThinkingConfig(
|
|
923
|
+
mode=cast(
|
|
924
|
+
Literal["enabled", "adaptive", "disabled"],
|
|
925
|
+
_validate_mode(
|
|
926
|
+
"thinking.mode",
|
|
927
|
+
_resolve_string(
|
|
928
|
+
thinking_raw.get("mode", "adaptive"),
|
|
929
|
+
"BAREAGENT_THINKING_MODE",
|
|
930
|
+
),
|
|
931
|
+
VALID_THINKING_MODES,
|
|
932
|
+
),
|
|
933
|
+
),
|
|
934
|
+
budget_tokens=_resolve_int(
|
|
935
|
+
int(thinking_raw.get("budget_tokens", 10000)),
|
|
936
|
+
"BAREAGENT_THINKING_BUDGET_TOKENS",
|
|
937
|
+
),
|
|
938
|
+
)
|
|
939
|
+
debug = DebugConfig(
|
|
940
|
+
enabled=_resolve_bool(
|
|
941
|
+
bool(debug_raw.get("enabled", False)),
|
|
942
|
+
"BAREAGENT_DEBUG",
|
|
943
|
+
),
|
|
944
|
+
log_dir=_resolve_string(
|
|
945
|
+
str(debug_raw.get("log_dir", ".logs")),
|
|
946
|
+
"BAREAGENT_DEBUG_LOG_DIR",
|
|
947
|
+
),
|
|
948
|
+
viewer_port=_resolve_int(
|
|
949
|
+
int(debug_raw.get("viewer_port", 8321)),
|
|
950
|
+
"BAREAGENT_DEBUG_VIEWER_PORT",
|
|
951
|
+
),
|
|
952
|
+
pretty=_resolve_bool(
|
|
953
|
+
bool(debug_raw.get("pretty", True)),
|
|
954
|
+
"BAREAGENT_DEBUG_PRETTY",
|
|
955
|
+
),
|
|
956
|
+
)
|
|
957
|
+
tracing = TracingConfig(
|
|
958
|
+
langfuse=_resolve_bool(
|
|
959
|
+
bool(tracing_raw.get("langfuse", False)),
|
|
960
|
+
"BAREAGENT_TRACING_LANGFUSE",
|
|
961
|
+
),
|
|
962
|
+
opentelemetry=_resolve_bool(
|
|
963
|
+
bool(tracing_raw.get("opentelemetry", False)),
|
|
964
|
+
"BAREAGENT_TRACING_OPENTELEMETRY",
|
|
965
|
+
),
|
|
966
|
+
content_enabled=_resolve_bool(
|
|
967
|
+
bool(tracing_raw.get("content_enabled", True)),
|
|
968
|
+
"BAREAGENT_CONTENT_TRACING_ENABLED",
|
|
969
|
+
),
|
|
970
|
+
)
|
|
971
|
+
|
|
972
|
+
mcp_raw = raw_config.get("mcp", {})
|
|
973
|
+
try:
|
|
974
|
+
mcp_config = parse_mcp_config({"mcp": mcp_raw} if isinstance(mcp_raw, dict) else {})
|
|
975
|
+
except MCPError as exc:
|
|
976
|
+
print(f"Warning: invalid [mcp] config, MCP disabled ({exc})")
|
|
977
|
+
mcp_config = MCPConfig()
|
|
978
|
+
|
|
979
|
+
lsp_raw = raw_config.get("lsp", {})
|
|
980
|
+
try:
|
|
981
|
+
lsp_config = parse_lsp_config({"lsp": lsp_raw} if isinstance(lsp_raw, dict) else {})
|
|
982
|
+
except LSPError as exc:
|
|
983
|
+
print(f"Warning: invalid [lsp] config, LSP disabled ({exc})")
|
|
984
|
+
lsp_config = LSPConfig()
|
|
985
|
+
|
|
986
|
+
hooks_raw = raw_config.get("hooks", [])
|
|
987
|
+
try:
|
|
988
|
+
hooks_config = parse_hooks_config(
|
|
989
|
+
{"hooks": hooks_raw} if isinstance(hooks_raw, list) else {}
|
|
990
|
+
)
|
|
991
|
+
except HookConfigError as exc:
|
|
992
|
+
print(f"Warning: invalid [[hooks]] config, hooks disabled ({exc})")
|
|
993
|
+
hooks_config = HooksConfig()
|
|
994
|
+
for skipped_reason in hooks_config.skipped:
|
|
995
|
+
print(f"Warning: {skipped_reason}")
|
|
996
|
+
|
|
997
|
+
cost_raw = raw_config.get("cost", {})
|
|
998
|
+
cost_config = _parse_cost_config(cost_raw if isinstance(cost_raw, dict) else {})
|
|
999
|
+
|
|
1000
|
+
retry_raw = raw_config.get("retry", {})
|
|
1001
|
+
retry_config = _parse_retry_config(retry_raw if isinstance(retry_raw, dict) else {})
|
|
1002
|
+
|
|
1003
|
+
cache_raw = raw_config.get("cache", {})
|
|
1004
|
+
cache_config = _parse_cache_config(cache_raw if isinstance(cache_raw, dict) else {})
|
|
1005
|
+
|
|
1006
|
+
skills_raw = raw_config.get("skills", {})
|
|
1007
|
+
skills_config = _parse_skills_config(skills_raw if isinstance(skills_raw, dict) else {})
|
|
1008
|
+
|
|
1009
|
+
goal_raw = raw_config.get("goal", {})
|
|
1010
|
+
goal_config = _parse_goal_config(goal_raw if isinstance(goal_raw, dict) else {})
|
|
1011
|
+
|
|
1012
|
+
workflow_raw = raw_config.get("workflow", {})
|
|
1013
|
+
workflow_config = _parse_workflow_config(workflow_raw if isinstance(workflow_raw, dict) else {})
|
|
1014
|
+
|
|
1015
|
+
team_raw = raw_config.get("team", {})
|
|
1016
|
+
team_config = _parse_team_config(team_raw if isinstance(team_raw, dict) else {})
|
|
1017
|
+
|
|
1018
|
+
memory_raw = raw_config.get("memory", {})
|
|
1019
|
+
memory_config = MemoryConfig(
|
|
1020
|
+
enabled=_resolve_bool(
|
|
1021
|
+
bool(memory_raw.get("enabled", True)),
|
|
1022
|
+
"BAREAGENT_MEMORY_ENABLED",
|
|
1023
|
+
),
|
|
1024
|
+
dir=_resolve_string(
|
|
1025
|
+
str(memory_raw.get("dir", "")),
|
|
1026
|
+
"BAREAGENT_MEMORY_DIR",
|
|
1027
|
+
),
|
|
1028
|
+
max_index_lines=_resolve_int(
|
|
1029
|
+
int(memory_raw.get("max_index_lines", 200)),
|
|
1030
|
+
"BAREAGENT_MEMORY_MAX_INDEX_LINES",
|
|
1031
|
+
),
|
|
1032
|
+
recall_k=_resolve_int(
|
|
1033
|
+
int(memory_raw.get("recall_k", 5)),
|
|
1034
|
+
"BAREAGENT_MEMORY_RECALL_K",
|
|
1035
|
+
),
|
|
1036
|
+
semantic_recall=_resolve_bool(
|
|
1037
|
+
bool(memory_raw.get("semantic_recall", False)),
|
|
1038
|
+
"BAREAGENT_MEMORY_SEMANTIC_RECALL",
|
|
1039
|
+
),
|
|
1040
|
+
embedding_backend=str(memory_raw.get("embedding_backend", "openai")).strip()
|
|
1041
|
+
or "openai",
|
|
1042
|
+
embedding_model=str(memory_raw.get("embedding_model", "")).strip(),
|
|
1043
|
+
embedding_base_url=str(memory_raw.get("embedding_base_url", "")).strip(),
|
|
1044
|
+
embedding_api_key=str(memory_raw.get("embedding_api_key", "")).strip(),
|
|
1045
|
+
)
|
|
1046
|
+
|
|
1047
|
+
return Config(
|
|
1048
|
+
provider=provider,
|
|
1049
|
+
permission=permission,
|
|
1050
|
+
ui=ui,
|
|
1051
|
+
subagent=subagent,
|
|
1052
|
+
thinking=thinking,
|
|
1053
|
+
debug=debug,
|
|
1054
|
+
tracing=tracing,
|
|
1055
|
+
path=config_path.resolve(),
|
|
1056
|
+
mcp=mcp_config,
|
|
1057
|
+
lsp=lsp_config,
|
|
1058
|
+
memory=memory_config,
|
|
1059
|
+
cost=cost_config,
|
|
1060
|
+
hooks=hooks_config,
|
|
1061
|
+
retry=retry_config,
|
|
1062
|
+
cache=cache_config,
|
|
1063
|
+
skills=skills_config,
|
|
1064
|
+
goal=goal_config,
|
|
1065
|
+
workflow=workflow_config,
|
|
1066
|
+
team=team_config,
|
|
1067
|
+
)
|
|
1068
|
+
|
|
1069
|
+
|
|
1070
|
+
_NAG_REMINDER_PREFIX = "<nag-reminder>"
|
|
1071
|
+
_MEMORY_RECALL_PREFIX = "<memory-recall>"
|
|
1072
|
+
_PLAN_DIRECTIVE_PREFIX = "<plan-mode>"
|
|
1073
|
+
|
|
1074
|
+
|
|
1075
|
+
def _initial_messages(
|
|
1076
|
+
workspace: Path,
|
|
1077
|
+
skill_summary: str = "",
|
|
1078
|
+
memory_context: str = "",
|
|
1079
|
+
) -> list[dict[str, Any]]:
|
|
1080
|
+
return [
|
|
1081
|
+
{
|
|
1082
|
+
"role": "system",
|
|
1083
|
+
"content": assemble_system_prompt(
|
|
1084
|
+
workspace,
|
|
1085
|
+
skill_summary=skill_summary,
|
|
1086
|
+
memory_context=memory_context,
|
|
1087
|
+
),
|
|
1088
|
+
}
|
|
1089
|
+
]
|
|
1090
|
+
|
|
1091
|
+
|
|
1092
|
+
def _build_memory_manager(
|
|
1093
|
+
config: Config,
|
|
1094
|
+
workspace_path: Path,
|
|
1095
|
+
ui_console: AgentConsole,
|
|
1096
|
+
) -> MemoryManager | None:
|
|
1097
|
+
"""Build the persistent memory manager, or None when disabled/unavailable."""
|
|
1098
|
+
if not config.memory.enabled:
|
|
1099
|
+
return None
|
|
1100
|
+
try:
|
|
1101
|
+
root = resolve_memory_root(workspace_path, config.memory.dir)
|
|
1102
|
+
embedder = _build_memory_embedder(config, ui_console)
|
|
1103
|
+
return MemoryManager(
|
|
1104
|
+
root,
|
|
1105
|
+
max_index_lines=config.memory.max_index_lines,
|
|
1106
|
+
embedder=embedder,
|
|
1107
|
+
)
|
|
1108
|
+
except OSError as exc:
|
|
1109
|
+
ui_console.print_error(f"Persistent memory disabled (cannot open store): {exc}")
|
|
1110
|
+
return None
|
|
1111
|
+
|
|
1112
|
+
|
|
1113
|
+
def _build_memory_embedder(config: Config, ui_console: AgentConsole):
|
|
1114
|
+
"""Build the semantic-recall embedder, or None (fail-open to lexical recall).
|
|
1115
|
+
|
|
1116
|
+
Off unless ``[memory] semantic_recall`` is set. The openai backend's base_url
|
|
1117
|
+
/ api_key fall back to the session provider's when unset; ``build_embedder``
|
|
1118
|
+
returns None on any failure (missing fastembed extra, missing key, unknown
|
|
1119
|
+
backend) so recall silently degrades to lexical.
|
|
1120
|
+
"""
|
|
1121
|
+
memory = config.memory
|
|
1122
|
+
if not memory.semantic_recall:
|
|
1123
|
+
return None
|
|
1124
|
+
backend = (memory.embedding_backend or "openai").strip().lower()
|
|
1125
|
+
if backend == "openai":
|
|
1126
|
+
api_key = memory.embedding_api_key
|
|
1127
|
+
if not api_key:
|
|
1128
|
+
# _resolve_api_key raises when the provider config has no key at
|
|
1129
|
+
# all; semantic recall is fail-open, so swallow that and let
|
|
1130
|
+
# build_embedder degrade to None rather than crash boot.
|
|
1131
|
+
try:
|
|
1132
|
+
api_key = _resolve_api_key(config.provider)
|
|
1133
|
+
except ValueError:
|
|
1134
|
+
api_key = ""
|
|
1135
|
+
embedder = build_embedder(
|
|
1136
|
+
"openai",
|
|
1137
|
+
memory.embedding_model,
|
|
1138
|
+
base_url=memory.embedding_base_url or config.provider.base_url or None,
|
|
1139
|
+
api_key=api_key,
|
|
1140
|
+
)
|
|
1141
|
+
else:
|
|
1142
|
+
embedder = build_embedder(backend, memory.embedding_model)
|
|
1143
|
+
if embedder is None:
|
|
1144
|
+
ui_console.print_status(
|
|
1145
|
+
"Semantic recall requested but the embedding backend is unavailable; "
|
|
1146
|
+
"using lexical recall."
|
|
1147
|
+
)
|
|
1148
|
+
return embedder
|
|
1149
|
+
|
|
1150
|
+
|
|
1151
|
+
def _memory_context(memory_manager: MemoryManager | None) -> str:
|
|
1152
|
+
return memory_manager.system_prompt_section() if memory_manager is not None else ""
|
|
1153
|
+
|
|
1154
|
+
|
|
1155
|
+
def _refresh_nag_reminder(
|
|
1156
|
+
messages: list[dict[str, Any]],
|
|
1157
|
+
nag_reminder: str | None,
|
|
1158
|
+
) -> None:
|
|
1159
|
+
messages[:] = [
|
|
1160
|
+
message
|
|
1161
|
+
for message in messages
|
|
1162
|
+
if not (
|
|
1163
|
+
message.get("role") == "system"
|
|
1164
|
+
and isinstance(message.get("content"), str)
|
|
1165
|
+
and str(message["content"]).startswith(_NAG_REMINDER_PREFIX)
|
|
1166
|
+
)
|
|
1167
|
+
]
|
|
1168
|
+
if not nag_reminder:
|
|
1169
|
+
return
|
|
1170
|
+
|
|
1171
|
+
nag_message = {
|
|
1172
|
+
"role": "system",
|
|
1173
|
+
"content": f"{_NAG_REMINDER_PREFIX}\n{nag_reminder.strip()}\n</nag-reminder>",
|
|
1174
|
+
}
|
|
1175
|
+
for index in range(len(messages) - 1, -1, -1):
|
|
1176
|
+
msg = messages[index]
|
|
1177
|
+
if msg.get("role") == "user" and not is_tool_result_message(msg):
|
|
1178
|
+
messages.insert(index + 1, nag_message)
|
|
1179
|
+
return
|
|
1180
|
+
|
|
1181
|
+
messages.append(nag_message)
|
|
1182
|
+
|
|
1183
|
+
|
|
1184
|
+
def _refresh_memory_recall(
|
|
1185
|
+
messages: list[dict[str, Any]],
|
|
1186
|
+
memory_manager: MemoryManager | None,
|
|
1187
|
+
recall_k: int,
|
|
1188
|
+
) -> None:
|
|
1189
|
+
"""Drop the stale recall block and inject one for the latest user turn.
|
|
1190
|
+
|
|
1191
|
+
Mirrors :func:`_refresh_nag_reminder`: a single ``<memory-recall>`` system
|
|
1192
|
+
message lives just after the most recent genuine user message, refreshed on
|
|
1193
|
+
every agent-loop iteration so ``/remember``, ``/forget`` and ordinary turns
|
|
1194
|
+
all pick up the latest lexically-relevant memories.
|
|
1195
|
+
"""
|
|
1196
|
+
messages[:] = [
|
|
1197
|
+
message
|
|
1198
|
+
for message in messages
|
|
1199
|
+
if not (
|
|
1200
|
+
message.get("role") == "system"
|
|
1201
|
+
and isinstance(message.get("content"), str)
|
|
1202
|
+
and str(message["content"]).startswith(_MEMORY_RECALL_PREFIX)
|
|
1203
|
+
)
|
|
1204
|
+
]
|
|
1205
|
+
if memory_manager is None or recall_k <= 0:
|
|
1206
|
+
return
|
|
1207
|
+
|
|
1208
|
+
query: str | None = None
|
|
1209
|
+
insert_index: int | None = None
|
|
1210
|
+
for index in range(len(messages) - 1, -1, -1):
|
|
1211
|
+
msg = messages[index]
|
|
1212
|
+
if msg.get("role") == "user" and not is_tool_result_message(msg):
|
|
1213
|
+
content = msg.get("content")
|
|
1214
|
+
if isinstance(content, str):
|
|
1215
|
+
query = content
|
|
1216
|
+
insert_index = index
|
|
1217
|
+
break
|
|
1218
|
+
if query is None or insert_index is None:
|
|
1219
|
+
return
|
|
1220
|
+
|
|
1221
|
+
section = memory_manager.recall_section(query, recall_k)
|
|
1222
|
+
if not section:
|
|
1223
|
+
return
|
|
1224
|
+
|
|
1225
|
+
messages.insert(insert_index + 1, {"role": "system", "content": section})
|
|
1226
|
+
|
|
1227
|
+
|
|
1228
|
+
def _refresh_plan_directive(
|
|
1229
|
+
messages: list[dict[str, Any]],
|
|
1230
|
+
permission: PermissionGuard,
|
|
1231
|
+
) -> None:
|
|
1232
|
+
"""Drop any stale plan-mode directive and re-inject it while in PLAN mode.
|
|
1233
|
+
|
|
1234
|
+
Mirrors :func:`_refresh_nag_reminder`. Because ``compact`` runs at the top
|
|
1235
|
+
of every agent-loop iteration (``loop.py``), approving a plan mid-loop flips
|
|
1236
|
+
``permission.mode`` and the *next* iteration strips this block automatically
|
|
1237
|
+
-- no stale plan guidance lingers once execution begins.
|
|
1238
|
+
"""
|
|
1239
|
+
messages[:] = [
|
|
1240
|
+
message
|
|
1241
|
+
for message in messages
|
|
1242
|
+
if not (
|
|
1243
|
+
message.get("role") == "system"
|
|
1244
|
+
and isinstance(message.get("content"), str)
|
|
1245
|
+
and str(message["content"]).startswith(_PLAN_DIRECTIVE_PREFIX)
|
|
1246
|
+
)
|
|
1247
|
+
]
|
|
1248
|
+
if permission.mode != PermissionMode.PLAN:
|
|
1249
|
+
return
|
|
1250
|
+
|
|
1251
|
+
directive = {
|
|
1252
|
+
"role": "system",
|
|
1253
|
+
"content": f"{_PLAN_DIRECTIVE_PREFIX}\n{PLAN_MODE_DIRECTIVE}\n</plan-mode>",
|
|
1254
|
+
}
|
|
1255
|
+
for index in range(len(messages) - 1, -1, -1):
|
|
1256
|
+
msg = messages[index]
|
|
1257
|
+
if msg.get("role") == "user" and not is_tool_result_message(msg):
|
|
1258
|
+
messages.insert(index + 1, directive)
|
|
1259
|
+
return
|
|
1260
|
+
|
|
1261
|
+
messages.append(directive)
|
|
1262
|
+
|
|
1263
|
+
|
|
1264
|
+
def _build_loop_compact(
|
|
1265
|
+
compact_fn: object,
|
|
1266
|
+
todo_manager: TodoManager,
|
|
1267
|
+
memory_manager: MemoryManager | None = None,
|
|
1268
|
+
recall_k: int = 0,
|
|
1269
|
+
permission: PermissionGuard | None = None,
|
|
1270
|
+
):
|
|
1271
|
+
def _compact(
|
|
1272
|
+
messages: list[dict[str, Any]],
|
|
1273
|
+
force: bool = False,
|
|
1274
|
+
) -> None:
|
|
1275
|
+
_refresh_nag_reminder(messages, todo_manager.get_nag_reminder())
|
|
1276
|
+
_refresh_memory_recall(messages, memory_manager, recall_k)
|
|
1277
|
+
if permission is not None:
|
|
1278
|
+
_refresh_plan_directive(messages, permission)
|
|
1279
|
+
compact_fn(messages, force=force) # type: ignore[misc]
|
|
1280
|
+
|
|
1281
|
+
get_session_id = getattr(compact_fn, "get_session_id", None)
|
|
1282
|
+
if callable(get_session_id):
|
|
1283
|
+
_compact.get_session_id = get_session_id # type: ignore[attr-defined]
|
|
1284
|
+
|
|
1285
|
+
set_session_id = getattr(compact_fn, "set_session_id", None)
|
|
1286
|
+
if callable(set_session_id):
|
|
1287
|
+
_compact.set_session_id = set_session_id # type: ignore[attr-defined]
|
|
1288
|
+
|
|
1289
|
+
return _compact
|
|
1290
|
+
|
|
1291
|
+
|
|
1292
|
+
_PERMISSION_SLASH = {
|
|
1293
|
+
"/default": PermissionMode.DEFAULT,
|
|
1294
|
+
"/auto": PermissionMode.AUTO,
|
|
1295
|
+
"/plan": PermissionMode.PLAN,
|
|
1296
|
+
"/bypass": PermissionMode.BYPASS,
|
|
1297
|
+
}
|
|
1298
|
+
_MODE_CYCLE = [
|
|
1299
|
+
PermissionMode.DEFAULT,
|
|
1300
|
+
PermissionMode.AUTO,
|
|
1301
|
+
PermissionMode.PLAN,
|
|
1302
|
+
PermissionMode.BYPASS,
|
|
1303
|
+
]
|
|
1304
|
+
_MODE_DESCRIPTIONS = {
|
|
1305
|
+
PermissionMode.DEFAULT: "Write operations require confirmation",
|
|
1306
|
+
PermissionMode.AUTO: "Safe commands auto-approved",
|
|
1307
|
+
PermissionMode.PLAN: "Read-only mode",
|
|
1308
|
+
PermissionMode.BYPASS: "No confirmation required",
|
|
1309
|
+
}
|
|
1310
|
+
_SLASH_COMMANDS = [
|
|
1311
|
+
"/help",
|
|
1312
|
+
"/exit",
|
|
1313
|
+
"/clear",
|
|
1314
|
+
"/new",
|
|
1315
|
+
"/compact",
|
|
1316
|
+
*_PERMISSION_SLASH,
|
|
1317
|
+
"/mode",
|
|
1318
|
+
"/theme",
|
|
1319
|
+
"/sessions",
|
|
1320
|
+
"/resume",
|
|
1321
|
+
"/export",
|
|
1322
|
+
"/import",
|
|
1323
|
+
"/cost",
|
|
1324
|
+
"/goal",
|
|
1325
|
+
"/loop",
|
|
1326
|
+
"/workflows",
|
|
1327
|
+
"/log",
|
|
1328
|
+
"/team",
|
|
1329
|
+
"/mcp",
|
|
1330
|
+
"/mcp:",
|
|
1331
|
+
"/lsp",
|
|
1332
|
+
"/reload",
|
|
1333
|
+
"/remember",
|
|
1334
|
+
"/forget",
|
|
1335
|
+
"/skill",
|
|
1336
|
+
]
|
|
1337
|
+
_HELP_TEXT = (
|
|
1338
|
+
"Available commands:\n"
|
|
1339
|
+
" /help Show this help message\n"
|
|
1340
|
+
" /exit Exit BareAgent\n"
|
|
1341
|
+
" /clear Clear screen and start new conversation\n"
|
|
1342
|
+
" /new Start a new conversation\n"
|
|
1343
|
+
" /compact Compress conversation context\n"
|
|
1344
|
+
" /default Switch to DEFAULT permission mode\n"
|
|
1345
|
+
" /auto Switch to AUTO permission mode\n"
|
|
1346
|
+
" /plan Switch to PLAN permission mode\n"
|
|
1347
|
+
" /bypass Switch to BYPASS permission mode\n"
|
|
1348
|
+
" /mode Interactive permission mode selection\n"
|
|
1349
|
+
" /theme Switch color theme (catppuccin-mocha, dracula, nord, tokyo-night, gruvbox)\n"
|
|
1350
|
+
" /sessions List saved sessions\n"
|
|
1351
|
+
" /resume Resume a previous session\n"
|
|
1352
|
+
" /export Export conversation (markdown default | json) [path]\n"
|
|
1353
|
+
" /import Import a conversation file (.json/.jsonl) into a new session\n"
|
|
1354
|
+
" /cost Show token usage and estimated cost for this session\n"
|
|
1355
|
+
" /goal Drive the agent until a condition is met "
|
|
1356
|
+
"(/goal [--max-turns N] <condition>); respects current permission mode\n"
|
|
1357
|
+
" /loop Schedule a shell command to repeat every N seconds "
|
|
1358
|
+
"(list|cancel <id>|clear); runs WITHOUT permission prompts\n"
|
|
1359
|
+
" /workflows List/inspect workflow runs (list | <run-id> | clear)\n"
|
|
1360
|
+
" /log Debug log viewer (status|serve|open|<seq>)\n"
|
|
1361
|
+
" /team Manage team agents (list | spawn | send | shutdown | register | review)\n"
|
|
1362
|
+
" /mcp Manage MCP servers (status | list | reload <name>)\n"
|
|
1363
|
+
" /mcp: Invoke an MCP prompt (e.g. /mcp:server:prompt key=value)\n"
|
|
1364
|
+
" /lsp Manage LSP servers (status | list | reload <language>)\n"
|
|
1365
|
+
" /reload Reload config.toml (theme + permission hot-apply; others need restart)\n"
|
|
1366
|
+
" /remember Save information to persistent memory (/remember <text>)\n"
|
|
1367
|
+
" /forget Remove information from persistent memory (/forget <text>)\n"
|
|
1368
|
+
" /skill Manage generated skills (list | keep <name> | discard <name>)"
|
|
1369
|
+
)
|
|
1370
|
+
|
|
1371
|
+
|
|
1372
|
+
def _build_permission_guard(config: Config) -> PermissionGuard:
|
|
1373
|
+
guard = PermissionGuard(PermissionMode(config.permission.mode))
|
|
1374
|
+
guard.allow_rules = list(config.permission.allow)
|
|
1375
|
+
guard.deny_rules = list(config.permission.deny)
|
|
1376
|
+
return guard
|
|
1377
|
+
|
|
1378
|
+
|
|
1379
|
+
def _format_config_change(change: ConfigChange) -> str:
|
|
1380
|
+
return f"{change.path} {change.old!r}→{change.new!r}"
|
|
1381
|
+
|
|
1382
|
+
|
|
1383
|
+
def _dispatch_reload_command(
|
|
1384
|
+
*,
|
|
1385
|
+
config: Config,
|
|
1386
|
+
permission: PermissionGuard,
|
|
1387
|
+
ui_console: AgentConsole,
|
|
1388
|
+
) -> None:
|
|
1389
|
+
"""Re-read config from disk and hot-apply the theme + permission subset.
|
|
1390
|
+
|
|
1391
|
+
All-or-nothing failure safety: if ``load_config`` raises (bad TOML, validation
|
|
1392
|
+
failure) the current live config is left untouched. Hot-reloadable changes
|
|
1393
|
+
(``ui.theme`` + ``permission.{mode,allow,deny}``) are applied to the live
|
|
1394
|
+
runtime objects *and* mirrored back into ``config`` so later reads stay
|
|
1395
|
+
consistent; everything else is only reported as "requires restart".
|
|
1396
|
+
"""
|
|
1397
|
+
from bareagent.ui.theme import format_unknown_theme, get_theme
|
|
1398
|
+
|
|
1399
|
+
try:
|
|
1400
|
+
new_config = load_config(config.path)
|
|
1401
|
+
except Exception as exc:
|
|
1402
|
+
ui_console.print_error(
|
|
1403
|
+
f"Reload failed ({type(exc).__name__}: {exc}). Keeping current config."
|
|
1404
|
+
)
|
|
1405
|
+
return
|
|
1406
|
+
|
|
1407
|
+
report = _diff_config_for_reload(config, new_config)
|
|
1408
|
+
if not report.changed:
|
|
1409
|
+
ui_console.print_status("Config unchanged.")
|
|
1410
|
+
return
|
|
1411
|
+
|
|
1412
|
+
applied: list[str] = []
|
|
1413
|
+
for change in report.hot:
|
|
1414
|
+
if change.path == "ui.theme":
|
|
1415
|
+
tm = get_theme()
|
|
1416
|
+
if tm.switch(new_config.ui.theme):
|
|
1417
|
+
ui_console.set_theme(tm)
|
|
1418
|
+
config.ui.theme = new_config.ui.theme
|
|
1419
|
+
applied.append(_format_config_change(change))
|
|
1420
|
+
else:
|
|
1421
|
+
ui_console.print_error(format_unknown_theme(new_config.ui.theme))
|
|
1422
|
+
elif change.path == "permission.mode":
|
|
1423
|
+
try:
|
|
1424
|
+
permission.mode = PermissionMode(new_config.permission.mode)
|
|
1425
|
+
except ValueError:
|
|
1426
|
+
ui_console.print_error(
|
|
1427
|
+
f"Invalid permission.mode {new_config.permission.mode!r}; skipped."
|
|
1428
|
+
)
|
|
1429
|
+
continue
|
|
1430
|
+
config.permission.mode = new_config.permission.mode
|
|
1431
|
+
applied.append(_format_config_change(change))
|
|
1432
|
+
elif change.path == "permission.allow":
|
|
1433
|
+
permission.allow_rules = list(new_config.permission.allow)
|
|
1434
|
+
config.permission.allow = list(new_config.permission.allow)
|
|
1435
|
+
applied.append(_format_config_change(change))
|
|
1436
|
+
elif change.path == "permission.deny":
|
|
1437
|
+
permission.deny_rules = list(new_config.permission.deny)
|
|
1438
|
+
config.permission.deny = list(new_config.permission.deny)
|
|
1439
|
+
applied.append(_format_config_change(change))
|
|
1440
|
+
|
|
1441
|
+
if applied:
|
|
1442
|
+
ui_console.print_status("Reloaded: " + ", ".join(applied))
|
|
1443
|
+
if report.restart:
|
|
1444
|
+
restart_summary = ", ".join(_format_config_change(change) for change in report.restart)
|
|
1445
|
+
ui_console.print_status(
|
|
1446
|
+
f"Changed but requires restart: {restart_summary} (restart BareAgent to apply)"
|
|
1447
|
+
)
|
|
1448
|
+
|
|
1449
|
+
|
|
1450
|
+
def _build_permission_allow_rule(
|
|
1451
|
+
tool_name: str,
|
|
1452
|
+
tool_input: dict[str, Any],
|
|
1453
|
+
) -> str | None:
|
|
1454
|
+
normalized_tool = tool_name.strip().lower()
|
|
1455
|
+
subject = permission_rule_subject(normalized_tool, tool_input)
|
|
1456
|
+
if not subject:
|
|
1457
|
+
return None
|
|
1458
|
+
if "\n" in subject or "\r" in subject:
|
|
1459
|
+
encoded_subject = json.dumps(subject, ensure_ascii=False)
|
|
1460
|
+
return f"{normalized_tool}(prefix_json:{encoded_subject})"
|
|
1461
|
+
return f"{normalized_tool}(prefix:{subject}*)"
|
|
1462
|
+
|
|
1463
|
+
|
|
1464
|
+
def _persist_permission_allow_rule(
|
|
1465
|
+
permission: PermissionGuard,
|
|
1466
|
+
tool_name: str,
|
|
1467
|
+
tool_input: dict[str, Any],
|
|
1468
|
+
) -> None:
|
|
1469
|
+
rule = _build_permission_allow_rule(tool_name, tool_input)
|
|
1470
|
+
if rule is None or rule in permission.allow_rules:
|
|
1471
|
+
return
|
|
1472
|
+
permission.allow_rules.append(rule)
|
|
1473
|
+
|
|
1474
|
+
|
|
1475
|
+
def _install_stdio_permission_prompt(
|
|
1476
|
+
permission: PermissionGuard,
|
|
1477
|
+
ui_console: AgentConsole,
|
|
1478
|
+
) -> None:
|
|
1479
|
+
if not sys.stdin.isatty():
|
|
1480
|
+
return
|
|
1481
|
+
|
|
1482
|
+
def _ask(call: Any) -> bool:
|
|
1483
|
+
preview_input = _build_permission_ask_payload(permission, call.name, call.input)
|
|
1484
|
+
allowed = ui_console.ask_permission(call.name, preview_input)
|
|
1485
|
+
choice = ui_console.consume_permission_choice()
|
|
1486
|
+
if allowed and choice == "always":
|
|
1487
|
+
_persist_permission_allow_rule(permission, call.name, call.input)
|
|
1488
|
+
return allowed
|
|
1489
|
+
|
|
1490
|
+
permission._ask_user_fn = _ask
|
|
1491
|
+
|
|
1492
|
+
|
|
1493
|
+
def _build_permission_ask_payload(
|
|
1494
|
+
permission: PermissionGuard,
|
|
1495
|
+
tool_name: str,
|
|
1496
|
+
tool_input: Any,
|
|
1497
|
+
) -> Any:
|
|
1498
|
+
"""Truncate oversized top-level string fields when asking about an MCP tool.
|
|
1499
|
+
|
|
1500
|
+
Non-MCP tools fall back to the raw input dict so existing rendering and
|
|
1501
|
+
permission rules stay unchanged. For MCP tools the guard's
|
|
1502
|
+
``format_preview`` rule (256 chars per top-level string) is applied by
|
|
1503
|
+
rebuilding the dict — the console layer keeps doing the JSON pretty-print.
|
|
1504
|
+
"""
|
|
1505
|
+
if not isinstance(tool_input, dict):
|
|
1506
|
+
return tool_input
|
|
1507
|
+
normalized = tool_name.strip().lower()
|
|
1508
|
+
if not normalized.startswith("mcp__"):
|
|
1509
|
+
return tool_input
|
|
1510
|
+
# Reuse the guard's truncation rule by parsing the formatted JSON back into
|
|
1511
|
+
# a dict — keeps the truncation logic in one place.
|
|
1512
|
+
try:
|
|
1513
|
+
truncated = json.loads(permission.format_preview(tool_name, tool_input))
|
|
1514
|
+
except (TypeError, ValueError):
|
|
1515
|
+
return tool_input
|
|
1516
|
+
if not isinstance(truncated, dict):
|
|
1517
|
+
return tool_input
|
|
1518
|
+
return truncated
|
|
1519
|
+
|
|
1520
|
+
|
|
1521
|
+
def _generate_session_id(
|
|
1522
|
+
transcript_mgr: TranscriptManager,
|
|
1523
|
+
*,
|
|
1524
|
+
reserved_ids: set[str] | None = None,
|
|
1525
|
+
) -> str:
|
|
1526
|
+
known_session_ids = set(transcript_mgr.list_sessions())
|
|
1527
|
+
if reserved_ids:
|
|
1528
|
+
known_session_ids.update(session_id for session_id in reserved_ids if session_id)
|
|
1529
|
+
|
|
1530
|
+
while True:
|
|
1531
|
+
candidate = (
|
|
1532
|
+
f"{datetime.now().strftime(_SESSION_ID_TIMESTAMP_FORMAT)}-{generate_random_id(6)}"
|
|
1533
|
+
)
|
|
1534
|
+
if candidate not in known_session_ids:
|
|
1535
|
+
return candidate
|
|
1536
|
+
|
|
1537
|
+
|
|
1538
|
+
def _switch_session_mailbox(
|
|
1539
|
+
workspace_path: Path,
|
|
1540
|
+
session_id: str,
|
|
1541
|
+
*,
|
|
1542
|
+
current_bus: MessageBus | None = None,
|
|
1543
|
+
) -> tuple[MessageBus, str | None]:
|
|
1544
|
+
if current_bus is not None:
|
|
1545
|
+
_broadcast_team_shutdown(current_bus)
|
|
1546
|
+
|
|
1547
|
+
message_bus = MessageBus(workspace_path / ".mailbox" / session_id)
|
|
1548
|
+
message_bus.ensure_mailbox(MAIN_AGENT_NAME)
|
|
1549
|
+
return message_bus, message_bus.latest_message_id(MAIN_AGENT_NAME)
|
|
1550
|
+
|
|
1551
|
+
|
|
1552
|
+
def _get_compact_session_id(compact_fn: object) -> str:
|
|
1553
|
+
getter = getattr(compact_fn, "get_session_id", None)
|
|
1554
|
+
if callable(getter):
|
|
1555
|
+
return str(getter())
|
|
1556
|
+
return "default"
|
|
1557
|
+
|
|
1558
|
+
|
|
1559
|
+
def _set_compact_session_id(compact_fn: object, session_id: str) -> None:
|
|
1560
|
+
setter = getattr(compact_fn, "set_session_id", None)
|
|
1561
|
+
if callable(setter):
|
|
1562
|
+
setter(session_id)
|
|
1563
|
+
|
|
1564
|
+
|
|
1565
|
+
def _save_transcript_snapshot(
|
|
1566
|
+
transcript_mgr: TranscriptManager,
|
|
1567
|
+
messages: list[dict[str, Any]],
|
|
1568
|
+
compact_fn: object,
|
|
1569
|
+
) -> None:
|
|
1570
|
+
transcript_mgr.save(messages, _get_compact_session_id(compact_fn))
|
|
1571
|
+
|
|
1572
|
+
|
|
1573
|
+
def _resolve_debug_log_dir(workspace_path: Path, config: Config) -> Path:
|
|
1574
|
+
log_dir = Path(config.debug.log_dir).expanduser()
|
|
1575
|
+
if not log_dir.is_absolute():
|
|
1576
|
+
log_dir = workspace_path / log_dir
|
|
1577
|
+
return log_dir
|
|
1578
|
+
|
|
1579
|
+
|
|
1580
|
+
def _build_interaction_logger(
|
|
1581
|
+
config: Config,
|
|
1582
|
+
workspace_path: Path,
|
|
1583
|
+
session_id: str,
|
|
1584
|
+
) -> InteractionLogger | None:
|
|
1585
|
+
if not config.debug.enabled:
|
|
1586
|
+
return None
|
|
1587
|
+
|
|
1588
|
+
return InteractionLogger(
|
|
1589
|
+
log_dir=_resolve_debug_log_dir(workspace_path, config),
|
|
1590
|
+
session_id=session_id,
|
|
1591
|
+
pretty=config.debug.pretty,
|
|
1592
|
+
)
|
|
1593
|
+
|
|
1594
|
+
|
|
1595
|
+
def _set_interaction_logger_session(
|
|
1596
|
+
interaction_logger: InteractionLogger | None,
|
|
1597
|
+
session_id: str,
|
|
1598
|
+
) -> None:
|
|
1599
|
+
if interaction_logger is None:
|
|
1600
|
+
return
|
|
1601
|
+
interaction_logger.session_id = session_id
|
|
1602
|
+
|
|
1603
|
+
|
|
1604
|
+
def _configure_tracing(
|
|
1605
|
+
config: Config,
|
|
1606
|
+
*,
|
|
1607
|
+
session_id: str = "default",
|
|
1608
|
+
interaction_logger: InteractionLogger | None = None,
|
|
1609
|
+
) -> None:
|
|
1610
|
+
from bareagent.tracing.setup import configure_tracing
|
|
1611
|
+
|
|
1612
|
+
configure_tracing(
|
|
1613
|
+
config.tracing,
|
|
1614
|
+
session_id=session_id,
|
|
1615
|
+
interaction_logger=interaction_logger,
|
|
1616
|
+
)
|
|
1617
|
+
|
|
1618
|
+
|
|
1619
|
+
def _debug_viewer_url(config: Config) -> str:
|
|
1620
|
+
return f"http://127.0.0.1:{config.debug.viewer_port}"
|
|
1621
|
+
|
|
1622
|
+
|
|
1623
|
+
def _format_log_status(
|
|
1624
|
+
config: Config,
|
|
1625
|
+
interaction_logger: InteractionLogger,
|
|
1626
|
+
viewer_server: object | None,
|
|
1627
|
+
) -> str:
|
|
1628
|
+
interactions = interaction_logger.list_interactions(interaction_logger.session_id)
|
|
1629
|
+
total_tokens = sum(
|
|
1630
|
+
int(interaction.get("input_tokens", 0) or 0) + int(interaction.get("output_tokens", 0) or 0)
|
|
1631
|
+
for interaction in interactions
|
|
1632
|
+
)
|
|
1633
|
+
lines = [
|
|
1634
|
+
"Debug logging: enabled",
|
|
1635
|
+
f"Log dir: {config.debug.log_dir}",
|
|
1636
|
+
f"Current session: {interaction_logger.session_id}",
|
|
1637
|
+
f"Interactions: {len(interactions)}",
|
|
1638
|
+
f"Total tokens: {total_tokens}",
|
|
1639
|
+
f"Sessions: {len(interaction_logger.list_sessions())}",
|
|
1640
|
+
]
|
|
1641
|
+
if viewer_server is None:
|
|
1642
|
+
lines.append("Viewer: not running (use /log serve)")
|
|
1643
|
+
else:
|
|
1644
|
+
lines.append(f"Viewer: {_debug_viewer_url(config)}")
|
|
1645
|
+
return "\n".join(lines)
|
|
1646
|
+
|
|
1647
|
+
|
|
1648
|
+
def _format_log_interaction_summary(
|
|
1649
|
+
seq: int,
|
|
1650
|
+
interaction: dict[str, object],
|
|
1651
|
+
) -> str:
|
|
1652
|
+
request = interaction.get("request")
|
|
1653
|
+
response = interaction.get("response")
|
|
1654
|
+
if not request and not response:
|
|
1655
|
+
return f"Interaction #{seq} not found."
|
|
1656
|
+
|
|
1657
|
+
response_data = response if isinstance(response, dict) else {}
|
|
1658
|
+
tool_calls = response_data.get("tool_calls", [])
|
|
1659
|
+
thinking = str(response_data.get("thinking", "") or "").strip()
|
|
1660
|
+
lines = [
|
|
1661
|
+
f"Interaction #{seq}:",
|
|
1662
|
+
f" Input tokens: {response_data.get('input_tokens', '?')}",
|
|
1663
|
+
f" Output tokens: {response_data.get('output_tokens', '?')}",
|
|
1664
|
+
f" Duration: {response_data.get('duration_ms', '?')}ms",
|
|
1665
|
+
f" Tool calls: {len(tool_calls) if isinstance(tool_calls, list) else 0}",
|
|
1666
|
+
]
|
|
1667
|
+
if response_data.get("error"):
|
|
1668
|
+
lines.append(f" Error: {response_data['error']}")
|
|
1669
|
+
if thinking:
|
|
1670
|
+
preview = thinking[:100]
|
|
1671
|
+
if len(thinking) > 100:
|
|
1672
|
+
preview += "..."
|
|
1673
|
+
lines.append(f" Thinking: {preview}")
|
|
1674
|
+
return "\n".join(lines)
|
|
1675
|
+
|
|
1676
|
+
|
|
1677
|
+
def _start_debug_viewer(
|
|
1678
|
+
interaction_logger: InteractionLogger,
|
|
1679
|
+
config: Config,
|
|
1680
|
+
) -> object:
|
|
1681
|
+
from bareagent.debug.web_viewer import start_viewer
|
|
1682
|
+
|
|
1683
|
+
viewer_server, _ = start_viewer(
|
|
1684
|
+
interaction_logger,
|
|
1685
|
+
port=config.debug.viewer_port,
|
|
1686
|
+
)
|
|
1687
|
+
return viewer_server
|
|
1688
|
+
|
|
1689
|
+
|
|
1690
|
+
def _handle_log_command(
|
|
1691
|
+
text: str,
|
|
1692
|
+
*,
|
|
1693
|
+
config: Config,
|
|
1694
|
+
interaction_logger: InteractionLogger | None,
|
|
1695
|
+
viewer_server: object | None,
|
|
1696
|
+
print_status: Callable[[str], None],
|
|
1697
|
+
) -> object | None:
|
|
1698
|
+
_, _, log_arg = text.partition(" ")
|
|
1699
|
+
log_cmd = log_arg.strip()
|
|
1700
|
+
|
|
1701
|
+
if interaction_logger is None:
|
|
1702
|
+
print_status(
|
|
1703
|
+
"Debug logging is disabled. Set [debug] enabled = true in config.toml "
|
|
1704
|
+
"or BAREAGENT_DEBUG=1"
|
|
1705
|
+
)
|
|
1706
|
+
return viewer_server
|
|
1707
|
+
|
|
1708
|
+
if not log_cmd or log_cmd == "status":
|
|
1709
|
+
print_status(_format_log_status(config, interaction_logger, viewer_server))
|
|
1710
|
+
return viewer_server
|
|
1711
|
+
|
|
1712
|
+
if log_cmd in {"serve", "open"}:
|
|
1713
|
+
if viewer_server is None:
|
|
1714
|
+
try:
|
|
1715
|
+
viewer_server = _start_debug_viewer(interaction_logger, config)
|
|
1716
|
+
except OSError as exc:
|
|
1717
|
+
print_status(f"Failed to start debug viewer: {exc}")
|
|
1718
|
+
return viewer_server
|
|
1719
|
+
print_status(f"Debug viewer started at {_debug_viewer_url(config)}")
|
|
1720
|
+
elif log_cmd == "serve":
|
|
1721
|
+
print_status(f"Viewer already running at {_debug_viewer_url(config)}")
|
|
1722
|
+
|
|
1723
|
+
if log_cmd == "open":
|
|
1724
|
+
import webbrowser
|
|
1725
|
+
|
|
1726
|
+
url = _debug_viewer_url(config)
|
|
1727
|
+
webbrowser.open(url)
|
|
1728
|
+
print_status(f"Opening {url} in browser...")
|
|
1729
|
+
return viewer_server
|
|
1730
|
+
|
|
1731
|
+
try:
|
|
1732
|
+
seq = int(log_cmd)
|
|
1733
|
+
except ValueError:
|
|
1734
|
+
print_status("Usage: /log [status|serve|open|<seq>]")
|
|
1735
|
+
return viewer_server
|
|
1736
|
+
|
|
1737
|
+
interaction = interaction_logger.get_interaction(
|
|
1738
|
+
interaction_logger.session_id,
|
|
1739
|
+
seq,
|
|
1740
|
+
)
|
|
1741
|
+
print_status(_format_log_interaction_summary(seq, interaction))
|
|
1742
|
+
return viewer_server
|
|
1743
|
+
|
|
1744
|
+
|
|
1745
|
+
def _build_handlers(
|
|
1746
|
+
*,
|
|
1747
|
+
workspace_path: Path,
|
|
1748
|
+
todo_manager: TodoManager,
|
|
1749
|
+
task_manager: TaskManager | None,
|
|
1750
|
+
skill_loader: SkillLoader,
|
|
1751
|
+
provider: BaseLLMProvider,
|
|
1752
|
+
tools: list[dict[str, object]],
|
|
1753
|
+
permission: PermissionGuard,
|
|
1754
|
+
bg_manager: BackgroundManager,
|
|
1755
|
+
messages: list[dict[str, Any]],
|
|
1756
|
+
config: Config,
|
|
1757
|
+
runtime_id: str,
|
|
1758
|
+
teammate_manager: TeammateManager,
|
|
1759
|
+
message_bus: MessageBus,
|
|
1760
|
+
spawned_agents: dict[str, AutonomousAgent],
|
|
1761
|
+
agent_name: str,
|
|
1762
|
+
mcp_manager: MCPManager | None = None,
|
|
1763
|
+
lsp_manager: LanguageServerManager | None = None,
|
|
1764
|
+
memory_manager: MemoryManager | None = None,
|
|
1765
|
+
system_prompt_override: str | None = None,
|
|
1766
|
+
subagent_registry: SubagentRegistry | None = None,
|
|
1767
|
+
) -> dict[str, Callable[..., Any]]:
|
|
1768
|
+
system_prompt = system_prompt_override or _extract_system_prompt(messages)
|
|
1769
|
+
team_handlers = _make_team_handlers(
|
|
1770
|
+
config=config,
|
|
1771
|
+
workspace_path=workspace_path,
|
|
1772
|
+
todo_manager=todo_manager,
|
|
1773
|
+
task_manager=task_manager,
|
|
1774
|
+
skill_loader=skill_loader,
|
|
1775
|
+
permission=permission,
|
|
1776
|
+
bg_manager=bg_manager,
|
|
1777
|
+
tools=tools,
|
|
1778
|
+
runtime_id=runtime_id,
|
|
1779
|
+
teammate_manager=teammate_manager,
|
|
1780
|
+
message_bus=message_bus,
|
|
1781
|
+
spawned_agents=spawned_agents,
|
|
1782
|
+
agent_name=agent_name,
|
|
1783
|
+
)
|
|
1784
|
+
return get_handlers(
|
|
1785
|
+
workspace_path,
|
|
1786
|
+
todo_manager=todo_manager,
|
|
1787
|
+
task_manager=task_manager,
|
|
1788
|
+
skill_loader=skill_loader,
|
|
1789
|
+
provider=provider,
|
|
1790
|
+
tools=tools,
|
|
1791
|
+
permission=permission,
|
|
1792
|
+
bg_manager=bg_manager,
|
|
1793
|
+
subagent_system_prompt=system_prompt,
|
|
1794
|
+
subagent_max_depth=config.subagent.max_depth,
|
|
1795
|
+
subagent_default_type=config.subagent.default_type,
|
|
1796
|
+
team_handlers=team_handlers,
|
|
1797
|
+
mcp_manager=mcp_manager,
|
|
1798
|
+
lsp_manager=lsp_manager,
|
|
1799
|
+
memory_manager=memory_manager,
|
|
1800
|
+
subagent_retry_policy=_build_retry_policy(config.retry),
|
|
1801
|
+
subagent_registry=subagent_registry,
|
|
1802
|
+
)
|
|
1803
|
+
|
|
1804
|
+
|
|
1805
|
+
def _load_task_manager(
|
|
1806
|
+
workspace_path: Path,
|
|
1807
|
+
agent_console: AgentConsole,
|
|
1808
|
+
) -> TaskManager | None:
|
|
1809
|
+
try:
|
|
1810
|
+
return TaskManager(workspace_path / ".tasks.json")
|
|
1811
|
+
except Exception as exc:
|
|
1812
|
+
agent_console.print_error(
|
|
1813
|
+
f"Failed to load task file {workspace_path / '.tasks.json'}: {exc}"
|
|
1814
|
+
)
|
|
1815
|
+
return None
|
|
1816
|
+
|
|
1817
|
+
|
|
1818
|
+
def _load_teammate_manager(
|
|
1819
|
+
workspace_path: Path,
|
|
1820
|
+
agent_console: AgentConsole,
|
|
1821
|
+
) -> TeammateManager:
|
|
1822
|
+
team_file = workspace_path / ".team.json"
|
|
1823
|
+
try:
|
|
1824
|
+
return TeammateManager(team_file)
|
|
1825
|
+
except Exception as exc:
|
|
1826
|
+
agent_console.print_error(f"Failed to load team file {team_file}: {exc}")
|
|
1827
|
+
return TeammateManager.create_empty(team_file)
|
|
1828
|
+
|
|
1829
|
+
|
|
1830
|
+
def _extract_system_prompt(messages: list[dict[str, Any]]) -> str:
|
|
1831
|
+
for message in messages:
|
|
1832
|
+
if message.get("role") != "system":
|
|
1833
|
+
continue
|
|
1834
|
+
content = message.get("content")
|
|
1835
|
+
if isinstance(content, str):
|
|
1836
|
+
return content
|
|
1837
|
+
return ""
|
|
1838
|
+
|
|
1839
|
+
|
|
1840
|
+
def _make_team_handlers(
|
|
1841
|
+
*,
|
|
1842
|
+
config: Config,
|
|
1843
|
+
workspace_path: Path,
|
|
1844
|
+
todo_manager: TodoManager,
|
|
1845
|
+
task_manager: TaskManager | None,
|
|
1846
|
+
skill_loader: SkillLoader,
|
|
1847
|
+
permission: PermissionGuard,
|
|
1848
|
+
bg_manager: BackgroundManager,
|
|
1849
|
+
tools: list[dict[str, object]],
|
|
1850
|
+
runtime_id: str,
|
|
1851
|
+
teammate_manager: TeammateManager,
|
|
1852
|
+
message_bus: MessageBus,
|
|
1853
|
+
spawned_agents: dict[str, AutonomousAgent],
|
|
1854
|
+
agent_name: str,
|
|
1855
|
+
) -> dict[str, Callable[..., Any]]:
|
|
1856
|
+
provider_factory = _make_teammate_provider_factory(config)
|
|
1857
|
+
|
|
1858
|
+
def _teammate_task_id(teammate_name: str) -> str:
|
|
1859
|
+
return f"team:{runtime_id}:{teammate_name}"
|
|
1860
|
+
|
|
1861
|
+
def _team_list() -> list[dict[str, object]]:
|
|
1862
|
+
return [
|
|
1863
|
+
{
|
|
1864
|
+
**teammate.to_dict(),
|
|
1865
|
+
# Source of truth is the live background thread, not the
|
|
1866
|
+
# spawned_agents dict (which never prunes crashed/finished
|
|
1867
|
+
# teammates).
|
|
1868
|
+
"running": bg_manager.is_running(_teammate_task_id(teammate.name)),
|
|
1869
|
+
}
|
|
1870
|
+
for teammate in teammate_manager.list()
|
|
1871
|
+
]
|
|
1872
|
+
|
|
1873
|
+
def _team_send(to_agent: str, content: str) -> str:
|
|
1874
|
+
normalized_target = to_agent.strip()
|
|
1875
|
+
if normalized_target != MAIN_AGENT_NAME:
|
|
1876
|
+
teammate_manager.get(normalized_target)
|
|
1877
|
+
message_id = message_bus.send(
|
|
1878
|
+
Message(
|
|
1879
|
+
id="",
|
|
1880
|
+
from_agent=agent_name,
|
|
1881
|
+
to_agent=normalized_target,
|
|
1882
|
+
content=content,
|
|
1883
|
+
msg_type="request",
|
|
1884
|
+
timestamp="",
|
|
1885
|
+
)
|
|
1886
|
+
)
|
|
1887
|
+
# The main agent has no autonomous responder, so never block on it.
|
|
1888
|
+
if normalized_target == MAIN_AGENT_NAME:
|
|
1889
|
+
return f"Sent message {message_id} to {normalized_target}."
|
|
1890
|
+
# A teammate that is not running will never reply; return now instead of
|
|
1891
|
+
# waiting out the full timeout. (If it is spawned later, a reply would
|
|
1892
|
+
# surface via the mailbox drain on a subsequent turn.)
|
|
1893
|
+
if not bg_manager.is_running(_teammate_task_id(normalized_target)):
|
|
1894
|
+
return (
|
|
1895
|
+
f"Sent message {message_id} to {normalized_target}, but it is not "
|
|
1896
|
+
"running. Spawn it first (team_spawn / /team spawn) to get a reply."
|
|
1897
|
+
)
|
|
1898
|
+
# Block for the reply and hand it back to the caller. Mark the response
|
|
1899
|
+
# delivered so the polling drain does not surface it to the LLM twice.
|
|
1900
|
+
timeout = config.team.response_timeout
|
|
1901
|
+
response = ProtocolFSM(message_bus, agent_name).wait_response(
|
|
1902
|
+
message_id, timeout=timeout
|
|
1903
|
+
)
|
|
1904
|
+
if response is None:
|
|
1905
|
+
return (
|
|
1906
|
+
f"Sent message {message_id} to {normalized_target}; no reply within "
|
|
1907
|
+
f"{timeout:.0f}s. It may still be working -- a late reply will "
|
|
1908
|
+
"surface on a later turn."
|
|
1909
|
+
)
|
|
1910
|
+
message_bus.mark_delivered(response.id)
|
|
1911
|
+
_, body = decode_protocol_content(response.content)
|
|
1912
|
+
return f"Reply from {normalized_target}: {body}"
|
|
1913
|
+
|
|
1914
|
+
def _team_spawn(name: str) -> str:
|
|
1915
|
+
teammate_name = name.strip()
|
|
1916
|
+
agent_instance = teammate_manager.spawn(teammate_name, provider_factory)
|
|
1917
|
+
message_bus.ensure_mailbox(teammate_name)
|
|
1918
|
+
teammate_permission = permission.clone(fail_closed=True)
|
|
1919
|
+
agent_handlers = _build_handlers(
|
|
1920
|
+
workspace_path=workspace_path,
|
|
1921
|
+
todo_manager=todo_manager,
|
|
1922
|
+
task_manager=task_manager,
|
|
1923
|
+
skill_loader=skill_loader,
|
|
1924
|
+
provider=agent_instance.provider,
|
|
1925
|
+
tools=tools,
|
|
1926
|
+
permission=teammate_permission,
|
|
1927
|
+
bg_manager=bg_manager,
|
|
1928
|
+
messages=[],
|
|
1929
|
+
config=config,
|
|
1930
|
+
runtime_id=runtime_id,
|
|
1931
|
+
teammate_manager=teammate_manager,
|
|
1932
|
+
message_bus=message_bus,
|
|
1933
|
+
spawned_agents=spawned_agents,
|
|
1934
|
+
agent_name=teammate_name,
|
|
1935
|
+
system_prompt_override=agent_instance.system_prompt,
|
|
1936
|
+
)
|
|
1937
|
+
# Conversational memory across requests (task 06-08): inject a per-teammate
|
|
1938
|
+
# Compactor (its own provider, no transcript persistence) so the growing
|
|
1939
|
+
# history stays bounded. Disabled -> no-op compaction + stateless behavior.
|
|
1940
|
+
memory_enabled = config.team.memory_enabled
|
|
1941
|
+
teammate_compact_fn = None
|
|
1942
|
+
if memory_enabled:
|
|
1943
|
+
teammate_compact_fn = Compactor(
|
|
1944
|
+
provider=agent_instance.provider,
|
|
1945
|
+
transcript_mgr=None,
|
|
1946
|
+
session_id=f"team:{teammate_name}",
|
|
1947
|
+
)
|
|
1948
|
+
autonomous_agent = AutonomousAgent(
|
|
1949
|
+
name=agent_instance.name,
|
|
1950
|
+
provider=agent_instance.provider,
|
|
1951
|
+
tools=tools,
|
|
1952
|
+
handlers=agent_handlers,
|
|
1953
|
+
bus=message_bus,
|
|
1954
|
+
task_manager=task_manager,
|
|
1955
|
+
permission=teammate_permission,
|
|
1956
|
+
system_prompt=agent_instance.system_prompt,
|
|
1957
|
+
poll_interval=config.team.poll_interval,
|
|
1958
|
+
compact_fn=teammate_compact_fn,
|
|
1959
|
+
memory_enabled=memory_enabled,
|
|
1960
|
+
)
|
|
1961
|
+
try:
|
|
1962
|
+
bg_manager.submit(_teammate_task_id(teammate_name), autonomous_agent.run)
|
|
1963
|
+
except ValueError:
|
|
1964
|
+
return f"Teammate {teammate_name} is already running."
|
|
1965
|
+
spawned_agents[teammate_name] = autonomous_agent
|
|
1966
|
+
return f"Spawned teammate {teammate_name} ({agent_instance.role})"
|
|
1967
|
+
|
|
1968
|
+
def _team_shutdown(name: str) -> str:
|
|
1969
|
+
teammate_name = name.strip()
|
|
1970
|
+
if not teammate_name:
|
|
1971
|
+
return "Error: teammate name must not be empty."
|
|
1972
|
+
if not bg_manager.is_running(_teammate_task_id(teammate_name)):
|
|
1973
|
+
spawned_agents.pop(teammate_name, None)
|
|
1974
|
+
return f"Teammate {teammate_name} is not running."
|
|
1975
|
+
# SHUTDOWN is honored regardless of msg_type (checked before the request
|
|
1976
|
+
# filter in AutonomousAgent._handle_messages); wait_for_message wakes the
|
|
1977
|
+
# daemon immediately so it stops promptly.
|
|
1978
|
+
ProtocolFSM(message_bus, agent_name).request(
|
|
1979
|
+
teammate_name, Protocol.SHUTDOWN, "Stop requested."
|
|
1980
|
+
)
|
|
1981
|
+
spawned_agents.pop(teammate_name, None)
|
|
1982
|
+
return f"Sent shutdown to teammate {teammate_name}."
|
|
1983
|
+
|
|
1984
|
+
def _team_register(
|
|
1985
|
+
name: str = "",
|
|
1986
|
+
role: str = "",
|
|
1987
|
+
system_prompt: str = "",
|
|
1988
|
+
provider: str = "",
|
|
1989
|
+
model: str = "",
|
|
1990
|
+
) -> str:
|
|
1991
|
+
# Build a sparse provider override; an empty config inherits the session
|
|
1992
|
+
# provider (the teammate provider factory fills the gaps).
|
|
1993
|
+
provider_config: dict[str, Any] = {}
|
|
1994
|
+
provider_name = (provider or "").strip()
|
|
1995
|
+
model_id = (model or "").strip()
|
|
1996
|
+
if provider_name:
|
|
1997
|
+
provider_config["name"] = provider_name
|
|
1998
|
+
if model_id:
|
|
1999
|
+
provider_config["model"] = model_id
|
|
2000
|
+
try:
|
|
2001
|
+
teammate = teammate_manager.register(
|
|
2002
|
+
name, role, system_prompt, provider_config=provider_config or None
|
|
2003
|
+
)
|
|
2004
|
+
except ValueError as exc:
|
|
2005
|
+
return f"Error: {exc}"
|
|
2006
|
+
return (
|
|
2007
|
+
f"Registered teammate {teammate.name} ({teammate.role}). "
|
|
2008
|
+
"Spawn it with team_spawn to start it."
|
|
2009
|
+
)
|
|
2010
|
+
|
|
2011
|
+
def _team_request_review(to_agent: str, plan: str) -> str:
|
|
2012
|
+
normalized_target = to_agent.strip()
|
|
2013
|
+
if not normalized_target:
|
|
2014
|
+
return "Error: to_agent must not be empty."
|
|
2015
|
+
if not isinstance(plan, str) or not plan.strip():
|
|
2016
|
+
return "Error: plan must not be empty."
|
|
2017
|
+
# The main agent has no autonomous responder, so it cannot review.
|
|
2018
|
+
if normalized_target == MAIN_AGENT_NAME:
|
|
2019
|
+
return "Cannot request review from the main agent (no autonomous responder)."
|
|
2020
|
+
try:
|
|
2021
|
+
teammate_manager.get(normalized_target)
|
|
2022
|
+
except ValueError:
|
|
2023
|
+
return (
|
|
2024
|
+
f"Error: unknown teammate {normalized_target}. "
|
|
2025
|
+
"Register it first with team_register."
|
|
2026
|
+
)
|
|
2027
|
+
# A teammate that is not running will never reply; return now instead of
|
|
2028
|
+
# waiting out the full timeout (mirrors team_send).
|
|
2029
|
+
if not bg_manager.is_running(_teammate_task_id(normalized_target)):
|
|
2030
|
+
return (
|
|
2031
|
+
f"Teammate {normalized_target} is not running. Spawn it first "
|
|
2032
|
+
"(team_spawn / /team spawn) to request a review."
|
|
2033
|
+
)
|
|
2034
|
+
# Send a PLAN_APPROVAL protocol request (the receiving teammate wraps it as
|
|
2035
|
+
# a plan-review prompt) and block for the verdict, deduping the reply so the
|
|
2036
|
+
# mailbox drain does not surface it to the LLM twice.
|
|
2037
|
+
fsm = ProtocolFSM(message_bus, agent_name)
|
|
2038
|
+
message_id = fsm.request(normalized_target, Protocol.PLAN_APPROVAL, plan)
|
|
2039
|
+
timeout = config.team.response_timeout
|
|
2040
|
+
response = fsm.wait_response(message_id, timeout=timeout)
|
|
2041
|
+
if response is None:
|
|
2042
|
+
return (
|
|
2043
|
+
f"Sent review request {message_id} to {normalized_target}; no verdict "
|
|
2044
|
+
f"within {timeout:.0f}s. It may still be reviewing -- a late reply will "
|
|
2045
|
+
"surface on a later turn."
|
|
2046
|
+
)
|
|
2047
|
+
message_bus.mark_delivered(response.id)
|
|
2048
|
+
_, verdict = decode_protocol_content(response.content)
|
|
2049
|
+
return f"Review verdict from {normalized_target}: {verdict}"
|
|
2050
|
+
|
|
2051
|
+
return {
|
|
2052
|
+
"team_list": _team_list,
|
|
2053
|
+
"team_send": _team_send,
|
|
2054
|
+
"team_spawn": _team_spawn,
|
|
2055
|
+
"team_shutdown": _team_shutdown,
|
|
2056
|
+
"team_register": _team_register,
|
|
2057
|
+
"team_request_review": _team_request_review,
|
|
2058
|
+
}
|
|
2059
|
+
|
|
2060
|
+
|
|
2061
|
+
def _make_teammate_provider_factory(config: Config):
|
|
2062
|
+
def _factory(provider_overrides: dict[str, object]) -> BaseLLMProvider:
|
|
2063
|
+
provider_name = str(provider_overrides.get("name", config.provider.name)).strip()
|
|
2064
|
+
resolved_provider_name = provider_name or config.provider.name
|
|
2065
|
+
inherited_api_key_env = (
|
|
2066
|
+
config.provider.api_key_env
|
|
2067
|
+
if resolved_provider_name == config.provider.name
|
|
2068
|
+
else _default_api_key_env(resolved_provider_name)
|
|
2069
|
+
)
|
|
2070
|
+
api_key_env = (
|
|
2071
|
+
str(
|
|
2072
|
+
provider_overrides.get(
|
|
2073
|
+
"api_key_env",
|
|
2074
|
+
inherited_api_key_env,
|
|
2075
|
+
)
|
|
2076
|
+
).strip()
|
|
2077
|
+
or inherited_api_key_env
|
|
2078
|
+
)
|
|
2079
|
+
provider_config = ProviderConfig(
|
|
2080
|
+
name=resolved_provider_name,
|
|
2081
|
+
model=str(provider_overrides.get("model", config.provider.model)).strip()
|
|
2082
|
+
or config.provider.model,
|
|
2083
|
+
api_key_env=api_key_env,
|
|
2084
|
+
base_url=_coerce_optional_string(
|
|
2085
|
+
provider_overrides.get("base_url", config.provider.base_url)
|
|
2086
|
+
),
|
|
2087
|
+
wire_api=_coerce_optional_string(
|
|
2088
|
+
provider_overrides.get("wire_api", config.provider.wire_api)
|
|
2089
|
+
),
|
|
2090
|
+
)
|
|
2091
|
+
return create_provider(
|
|
2092
|
+
SimpleNamespace(
|
|
2093
|
+
provider=provider_config,
|
|
2094
|
+
thinking=ThinkingConfig(
|
|
2095
|
+
mode=config.thinking.mode,
|
|
2096
|
+
budget_tokens=config.thinking.budget_tokens,
|
|
2097
|
+
),
|
|
2098
|
+
cache=CacheConfig(
|
|
2099
|
+
enabled=config.cache.enabled,
|
|
2100
|
+
ttl=config.cache.ttl,
|
|
2101
|
+
),
|
|
2102
|
+
)
|
|
2103
|
+
)
|
|
2104
|
+
|
|
2105
|
+
return _factory
|
|
2106
|
+
|
|
2107
|
+
|
|
2108
|
+
def _handle_team_command(
|
|
2109
|
+
text: str,
|
|
2110
|
+
ui_console: AgentConsole,
|
|
2111
|
+
*,
|
|
2112
|
+
teammate_manager: TeammateManager,
|
|
2113
|
+
team_handlers: dict[str, Callable[..., Any]],
|
|
2114
|
+
) -> None:
|
|
2115
|
+
_, _, raw_args = text.partition(" ")
|
|
2116
|
+
parts = raw_args.split(" ", 2) if raw_args else []
|
|
2117
|
+
subcommand = parts[0] if parts else "list"
|
|
2118
|
+
|
|
2119
|
+
try:
|
|
2120
|
+
if subcommand == "list":
|
|
2121
|
+
teammates = team_handlers["team_list"]() # type: ignore[index, operator]
|
|
2122
|
+
if not teammates:
|
|
2123
|
+
ui_console.print_status("No teammates registered.")
|
|
2124
|
+
return
|
|
2125
|
+
for teammate in teammates:
|
|
2126
|
+
name = str(teammate.get("name", "unknown"))
|
|
2127
|
+
role = str(teammate.get("role", ""))
|
|
2128
|
+
running = "running" if teammate.get("running") else "idle"
|
|
2129
|
+
ui_console.console.print(f"{name} [{running}] - {role}")
|
|
2130
|
+
return
|
|
2131
|
+
|
|
2132
|
+
if subcommand == "spawn" and len(parts) >= 2:
|
|
2133
|
+
result = team_handlers["team_spawn"](parts[1]) # type: ignore[index, operator]
|
|
2134
|
+
ui_console.print_status(str(result))
|
|
2135
|
+
return
|
|
2136
|
+
|
|
2137
|
+
if subcommand == "send" and len(parts) >= 3:
|
|
2138
|
+
target = parts[1].strip()
|
|
2139
|
+
content = parts[2].strip()
|
|
2140
|
+
if not target or not content:
|
|
2141
|
+
raise ValueError("Usage: /team send <name> <message>")
|
|
2142
|
+
result = team_handlers["team_send"](target, content) # type: ignore[index, operator]
|
|
2143
|
+
ui_console.print_status(str(result))
|
|
2144
|
+
return
|
|
2145
|
+
|
|
2146
|
+
if subcommand == "shutdown" and len(parts) >= 2:
|
|
2147
|
+
result = team_handlers["team_shutdown"](parts[1]) # type: ignore[index, operator]
|
|
2148
|
+
ui_console.print_status(str(result))
|
|
2149
|
+
return
|
|
2150
|
+
|
|
2151
|
+
if subcommand == "register":
|
|
2152
|
+
# name + role + system_prompt (the prompt is free text with spaces).
|
|
2153
|
+
reg_parts = raw_args.split(" ", 3)
|
|
2154
|
+
if len(reg_parts) < 4 or not reg_parts[1].strip() or not reg_parts[3].strip():
|
|
2155
|
+
raise ValueError("Usage: /team register <name> <role> <system_prompt>")
|
|
2156
|
+
result = team_handlers["team_register"]( # type: ignore[index, operator]
|
|
2157
|
+
reg_parts[1], reg_parts[2], reg_parts[3]
|
|
2158
|
+
)
|
|
2159
|
+
ui_console.print_status(str(result))
|
|
2160
|
+
return
|
|
2161
|
+
|
|
2162
|
+
if subcommand == "review" and len(parts) >= 3:
|
|
2163
|
+
target = parts[1].strip()
|
|
2164
|
+
plan = parts[2].strip()
|
|
2165
|
+
if not target or not plan:
|
|
2166
|
+
raise ValueError("Usage: /team review <name> <plan>")
|
|
2167
|
+
result = team_handlers["team_request_review"](target, plan) # type: ignore[index, operator]
|
|
2168
|
+
ui_console.print_status(str(result))
|
|
2169
|
+
return
|
|
2170
|
+
except Exception as exc:
|
|
2171
|
+
ui_console.print_error(str(exc))
|
|
2172
|
+
return
|
|
2173
|
+
|
|
2174
|
+
ui_console.print_status(
|
|
2175
|
+
"Usage: /team list | /team spawn <name> | /team send <name> <message> "
|
|
2176
|
+
"| /team shutdown <name> | /team register <name> <role> <system_prompt> "
|
|
2177
|
+
"| /team review <name> <plan>"
|
|
2178
|
+
)
|
|
2179
|
+
|
|
2180
|
+
|
|
2181
|
+
_MCP_PROMPT_USAGE = "Usage: /mcp:<server>:<prompt> [key=value ...]"
|
|
2182
|
+
_MCP_COMMAND_USAGE = "Usage: /mcp <status|list|reload <name>>"
|
|
2183
|
+
|
|
2184
|
+
|
|
2185
|
+
def _dispatch_mcp_command(
|
|
2186
|
+
text: str,
|
|
2187
|
+
*,
|
|
2188
|
+
mcp_manager: MCPManager,
|
|
2189
|
+
ui_console: AgentConsole,
|
|
2190
|
+
) -> None:
|
|
2191
|
+
"""Handle the space-prefixed ``/mcp <subcommand>`` REPL command.
|
|
2192
|
+
|
|
2193
|
+
Returns nothing — feedback goes through ``ui_console``. Unknown
|
|
2194
|
+
subcommands or missing args become ``print_error`` lines and never raise.
|
|
2195
|
+
"""
|
|
2196
|
+
tokens = text.split()
|
|
2197
|
+
if len(tokens) <= 1:
|
|
2198
|
+
ui_console.print_status(_MCP_COMMAND_USAGE)
|
|
2199
|
+
return
|
|
2200
|
+
sub = tokens[1]
|
|
2201
|
+
if sub == "status":
|
|
2202
|
+
rows = mcp_manager.summarize()
|
|
2203
|
+
if not rows:
|
|
2204
|
+
ui_console.print_status("(no MCP servers configured)")
|
|
2205
|
+
return
|
|
2206
|
+
for row in rows:
|
|
2207
|
+
resources_label = "resources" if row["has_resources"] else "no-resources"
|
|
2208
|
+
ui_console.print_status(
|
|
2209
|
+
f"{row['name']}: {row['status']} "
|
|
2210
|
+
f"[{row['tool_count']} tools, "
|
|
2211
|
+
f"{resources_label}, "
|
|
2212
|
+
f"{row['prompt_count']} prompts]"
|
|
2213
|
+
)
|
|
2214
|
+
return
|
|
2215
|
+
if sub == "list":
|
|
2216
|
+
any_server = False
|
|
2217
|
+
for name, client in mcp_manager.iter_running_clients():
|
|
2218
|
+
any_server = True
|
|
2219
|
+
ui_console.print_status(f"[{name}]")
|
|
2220
|
+
cached_tools = getattr(client, "_tools_cache", None) or []
|
|
2221
|
+
for tool in cached_tools:
|
|
2222
|
+
tool_name = tool.get("name") if isinstance(tool, dict) else None
|
|
2223
|
+
if not tool_name:
|
|
2224
|
+
continue
|
|
2225
|
+
ui_console.print_status(f" mcp__{name}__{tool_name}")
|
|
2226
|
+
if client.has_capability("resources"):
|
|
2227
|
+
ui_console.print_status(f" mcp__{name}__resource_list")
|
|
2228
|
+
ui_console.print_status(f" mcp__{name}__resource_read")
|
|
2229
|
+
cached_prompts = getattr(client, "_prompts", None) or []
|
|
2230
|
+
for prompt in cached_prompts:
|
|
2231
|
+
prompt_name = prompt.get("name") if isinstance(prompt, dict) else None
|
|
2232
|
+
if not prompt_name:
|
|
2233
|
+
continue
|
|
2234
|
+
ui_console.print_status(f" /mcp:{name}:{prompt_name}")
|
|
2235
|
+
if not any_server:
|
|
2236
|
+
ui_console.print_status("(no MCP servers running)")
|
|
2237
|
+
return
|
|
2238
|
+
if sub == "reload":
|
|
2239
|
+
if len(tokens) < 3:
|
|
2240
|
+
ui_console.print_error("Usage: /mcp reload <name>")
|
|
2241
|
+
return
|
|
2242
|
+
target = tokens[2]
|
|
2243
|
+
try:
|
|
2244
|
+
mcp_manager.reload(target)
|
|
2245
|
+
except MCPError as exc:
|
|
2246
|
+
ui_console.print_error(f"reload {target!r} failed: {exc}")
|
|
2247
|
+
ui_console.print_error(f"Server {target!r} is now UNHEALTHY.")
|
|
2248
|
+
return
|
|
2249
|
+
except Exception as exc:
|
|
2250
|
+
ui_console.print_error(f"reload {target!r} failed: {exc}")
|
|
2251
|
+
ui_console.print_error(f"Server {target!r} is now UNHEALTHY.")
|
|
2252
|
+
return
|
|
2253
|
+
ui_console.print_status(f"Server {target!r} reloaded.")
|
|
2254
|
+
return
|
|
2255
|
+
ui_console.print_error(f"Unknown /mcp subcommand: {sub}. Use status, list, or reload.")
|
|
2256
|
+
|
|
2257
|
+
|
|
2258
|
+
def _parse_mcp_prompt_command(text: str) -> tuple[str, str, dict[str, str]] | None:
|
|
2259
|
+
"""Parse ``/mcp:<server>:<prompt> [k=v ...]`` into (server, prompt, args).
|
|
2260
|
+
|
|
2261
|
+
Returns ``None`` if the command is malformed; logging is the caller's job
|
|
2262
|
+
so callers can surface UI feedback consistently.
|
|
2263
|
+
"""
|
|
2264
|
+
if not text.startswith("/mcp:"):
|
|
2265
|
+
return None
|
|
2266
|
+
rest = text[len("/mcp:") :]
|
|
2267
|
+
head, _, tail = rest.partition(" ")
|
|
2268
|
+
if ":" not in head:
|
|
2269
|
+
return None
|
|
2270
|
+
server_name, prompt_name = head.split(":", 1)
|
|
2271
|
+
server_name = server_name.strip()
|
|
2272
|
+
prompt_name = prompt_name.strip()
|
|
2273
|
+
if not server_name or not prompt_name:
|
|
2274
|
+
return None
|
|
2275
|
+
arguments: dict[str, str] = {}
|
|
2276
|
+
for tok in tail.split():
|
|
2277
|
+
if "=" not in tok:
|
|
2278
|
+
_log.warning("Ignoring malformed /mcp: argument %r (expected key=value)", tok)
|
|
2279
|
+
continue
|
|
2280
|
+
k, _sep, v = tok.partition("=")
|
|
2281
|
+
if not k:
|
|
2282
|
+
_log.warning("Ignoring malformed /mcp: argument %r (empty key)", tok)
|
|
2283
|
+
continue
|
|
2284
|
+
arguments[k] = v
|
|
2285
|
+
return server_name, prompt_name, arguments
|
|
2286
|
+
|
|
2287
|
+
|
|
2288
|
+
def _dispatch_mcp_prompt(
|
|
2289
|
+
text: str,
|
|
2290
|
+
*,
|
|
2291
|
+
mcp_manager: MCPManager,
|
|
2292
|
+
messages: list[dict[str, Any]],
|
|
2293
|
+
ui_console: AgentConsole,
|
|
2294
|
+
) -> bool:
|
|
2295
|
+
"""Handle a ``/mcp:`` slash command. Returns True if messages were appended.
|
|
2296
|
+
|
|
2297
|
+
On success the parsed ``prompts/get`` result is converted into transcript
|
|
2298
|
+
messages and appended; the caller is then expected to trigger the next
|
|
2299
|
+
``agent_loop()`` iteration just like a normal user input.
|
|
2300
|
+
"""
|
|
2301
|
+
parsed = _parse_mcp_prompt_command(text)
|
|
2302
|
+
if parsed is None:
|
|
2303
|
+
ui_console.print_error(_MCP_PROMPT_USAGE)
|
|
2304
|
+
return False
|
|
2305
|
+
server_name, prompt_name, arguments = parsed
|
|
2306
|
+
|
|
2307
|
+
client = mcp_manager.get_client(server_name)
|
|
2308
|
+
if client is None:
|
|
2309
|
+
ui_console.print_error(f"Error: MCP server {server_name!r} is not running")
|
|
2310
|
+
return False
|
|
2311
|
+
if not client.has_capability("prompts"):
|
|
2312
|
+
ui_console.print_error(f"Error: server {server_name!r} does not support prompts")
|
|
2313
|
+
return False
|
|
2314
|
+
|
|
2315
|
+
try:
|
|
2316
|
+
result = client.get_prompt(prompt_name, arguments)
|
|
2317
|
+
except MCPCallError as exc:
|
|
2318
|
+
ui_console.print_error(str(exc))
|
|
2319
|
+
return False
|
|
2320
|
+
except Exception as exc: # pragma: no cover — defensive
|
|
2321
|
+
ui_console.print_error(f"Error: {type(exc).__name__}: {exc}")
|
|
2322
|
+
return False
|
|
2323
|
+
|
|
2324
|
+
raw_messages = result.get("messages") if isinstance(result, dict) else None
|
|
2325
|
+
if not isinstance(raw_messages, list) or not raw_messages:
|
|
2326
|
+
ui_console.print_error(
|
|
2327
|
+
f"Error: prompt {prompt_name!r} from {server_name!r} returned no messages"
|
|
2328
|
+
)
|
|
2329
|
+
return False
|
|
2330
|
+
|
|
2331
|
+
appended = False
|
|
2332
|
+
for entry in raw_messages:
|
|
2333
|
+
if not isinstance(entry, dict):
|
|
2334
|
+
continue
|
|
2335
|
+
role = entry.get("role")
|
|
2336
|
+
if role not in {"user", "assistant"}:
|
|
2337
|
+
continue
|
|
2338
|
+
content = entry.get("content")
|
|
2339
|
+
if isinstance(content, list):
|
|
2340
|
+
blocks = content
|
|
2341
|
+
elif isinstance(content, dict):
|
|
2342
|
+
blocks = [content]
|
|
2343
|
+
elif isinstance(content, str):
|
|
2344
|
+
blocks = [{"type": "text", "text": content}]
|
|
2345
|
+
else:
|
|
2346
|
+
blocks = []
|
|
2347
|
+
text_body = _mcp_flatten_content(blocks)
|
|
2348
|
+
messages.append({"role": role, "content": text_body})
|
|
2349
|
+
appended = True
|
|
2350
|
+
|
|
2351
|
+
return appended
|
|
2352
|
+
|
|
2353
|
+
|
|
2354
|
+
def _drain_team_mailbox(
|
|
2355
|
+
ui_console: AgentConsole,
|
|
2356
|
+
*,
|
|
2357
|
+
message_bus: MessageBus,
|
|
2358
|
+
since: str | None,
|
|
2359
|
+
sink: list[str] | None = None,
|
|
2360
|
+
) -> str | None:
|
|
2361
|
+
"""Surface new main-mailbox messages to the console and (optionally) the LLM.
|
|
2362
|
+
|
|
2363
|
+
Messages already delivered out of band (a blocking ``team_send`` that
|
|
2364
|
+
returned the reply to the caller) are skipped so the LLM never sees them
|
|
2365
|
+
twice. When ``sink`` is provided, each surfaced message's text is appended to
|
|
2366
|
+
it; the REPL prepends those onto the next user turn so late / unsolicited
|
|
2367
|
+
teammate replies enter the conversation without breaking role alternation.
|
|
2368
|
+
"""
|
|
2369
|
+
messages = message_bus.receive(MAIN_AGENT_NAME, since_id=since)
|
|
2370
|
+
if not messages:
|
|
2371
|
+
return since
|
|
2372
|
+
|
|
2373
|
+
for message in messages:
|
|
2374
|
+
if message_bus.was_delivered(message.id):
|
|
2375
|
+
continue
|
|
2376
|
+
protocol, content = decode_protocol_content(message.content)
|
|
2377
|
+
label = f"Team {message.msg_type} from {message.from_agent}"
|
|
2378
|
+
if protocol is not None:
|
|
2379
|
+
label = f"{label} [{protocol.value}]"
|
|
2380
|
+
ui_console.print_status(f"{label}: {content}")
|
|
2381
|
+
if sink is not None:
|
|
2382
|
+
sink.append(f"[{label}] {content}")
|
|
2383
|
+
|
|
2384
|
+
return messages[-1].id
|
|
2385
|
+
|
|
2386
|
+
|
|
2387
|
+
def _drain_workflow_results(
|
|
2388
|
+
ui_console: AgentConsole,
|
|
2389
|
+
*,
|
|
2390
|
+
registry: WorkflowRegistry,
|
|
2391
|
+
sink: list[str],
|
|
2392
|
+
) -> None:
|
|
2393
|
+
"""Inject finished background workflows' full summaries into the next turn.
|
|
2394
|
+
|
|
2395
|
+
Mirrors ``_drain_team_mailbox``: ``take_undelivered`` returns each finished,
|
|
2396
|
+
not-yet-delivered run exactly once (marking it delivered), so a background
|
|
2397
|
+
workflow's full aggregated summary reaches the LLM precisely once and never
|
|
2398
|
+
twice. The summary is appended to ``sink`` (the REPL prepends it onto the next
|
|
2399
|
+
user turn, keeping role alternation intact) untruncated -- the generic
|
|
2400
|
+
background-task notification path skips ``wf-`` ids precisely so this drain is
|
|
2401
|
+
the single delivery channel. Sync runs are marked delivered but not injected:
|
|
2402
|
+
they already returned their summary as the tool result.
|
|
2403
|
+
"""
|
|
2404
|
+
for run in registry.take_undelivered():
|
|
2405
|
+
if not run.background:
|
|
2406
|
+
continue
|
|
2407
|
+
counts = run.counts()
|
|
2408
|
+
ui_console.print_status(
|
|
2409
|
+
f"Workflow {run.run_id} finished "
|
|
2410
|
+
f"({counts['done']} done, {counts['failed']} failed, {counts['skipped']} skipped)."
|
|
2411
|
+
)
|
|
2412
|
+
body = run.summary.strip() or "(no summary)"
|
|
2413
|
+
sink.append(f'<workflow-result run="{run.run_id}">\n{body}\n</workflow-result>')
|
|
2414
|
+
|
|
2415
|
+
|
|
2416
|
+
def _broadcast_team_shutdown(message_bus: MessageBus) -> None:
|
|
2417
|
+
ProtocolFSM(message_bus, MAIN_AGENT_NAME).broadcast(
|
|
2418
|
+
Protocol.SHUTDOWN,
|
|
2419
|
+
"BareAgent main session is shutting down.",
|
|
2420
|
+
)
|
|
2421
|
+
|
|
2422
|
+
|
|
2423
|
+
def _read_stdio_input() -> str:
|
|
2424
|
+
prompt = "bareagent> " if sys.stdin.isatty() and sys.stdout.isatty() else ""
|
|
2425
|
+
return input(prompt)
|
|
2426
|
+
|
|
2427
|
+
|
|
2428
|
+
def _cycle_permission_mode(permission: PermissionGuard) -> PermissionMode:
|
|
2429
|
+
current_index = _MODE_CYCLE.index(permission.mode)
|
|
2430
|
+
next_mode = _MODE_CYCLE[(current_index + 1) % len(_MODE_CYCLE)]
|
|
2431
|
+
permission.mode = next_mode
|
|
2432
|
+
return next_mode
|
|
2433
|
+
|
|
2434
|
+
|
|
2435
|
+
def _build_stdio_read_fn(
|
|
2436
|
+
workspace_path: Path,
|
|
2437
|
+
permission: PermissionGuard,
|
|
2438
|
+
) -> Callable[[], str]:
|
|
2439
|
+
if not sys.stdin.isatty() or not sys.stdout.isatty():
|
|
2440
|
+
return _read_stdio_input
|
|
2441
|
+
|
|
2442
|
+
try:
|
|
2443
|
+
from bareagent.ui.prompt import AgentPrompt
|
|
2444
|
+
|
|
2445
|
+
agent_prompt = AgentPrompt(
|
|
2446
|
+
commands=list(_SLASH_COMMANDS),
|
|
2447
|
+
history_file=workspace_path / ".bareagent_history",
|
|
2448
|
+
get_mode_label=lambda: permission.mode.value.upper(),
|
|
2449
|
+
cycle_mode=lambda: _cycle_permission_mode(permission).value.upper(),
|
|
2450
|
+
)
|
|
2451
|
+
return agent_prompt.read_input
|
|
2452
|
+
except Exception:
|
|
2453
|
+
return lambda: input(f"[{permission.mode.value.upper()}] bareagent> ")
|
|
2454
|
+
|
|
2455
|
+
|
|
2456
|
+
def _clear_stdio_screen(ui_console: AgentConsole) -> None:
|
|
2457
|
+
if getattr(ui_console.console, "is_terminal", False):
|
|
2458
|
+
ui_console.console.clear(home=True)
|
|
2459
|
+
|
|
2460
|
+
|
|
2461
|
+
def _print_stdio_user_message(ui_console: AgentConsole, text: str) -> None:
|
|
2462
|
+
if not text.strip():
|
|
2463
|
+
return
|
|
2464
|
+
|
|
2465
|
+
from bareagent.ui.theme import get_theme
|
|
2466
|
+
|
|
2467
|
+
ui_console.console.print(
|
|
2468
|
+
f"> {text}",
|
|
2469
|
+
style=f"bold {get_theme().palette.accent}",
|
|
2470
|
+
markup=False,
|
|
2471
|
+
)
|
|
2472
|
+
|
|
2473
|
+
|
|
2474
|
+
def _replay_stdio_transcript(
|
|
2475
|
+
messages: list[dict[str, Any]],
|
|
2476
|
+
ui_console: AgentConsole,
|
|
2477
|
+
) -> None:
|
|
2478
|
+
tool_name_by_id: dict[str, str] = {}
|
|
2479
|
+
|
|
2480
|
+
for message in messages:
|
|
2481
|
+
role = message.get("role")
|
|
2482
|
+
content = message.get("content")
|
|
2483
|
+
|
|
2484
|
+
if role == "system":
|
|
2485
|
+
continue
|
|
2486
|
+
|
|
2487
|
+
if role == "user":
|
|
2488
|
+
if isinstance(content, str):
|
|
2489
|
+
_print_stdio_user_message(ui_console, content)
|
|
2490
|
+
continue
|
|
2491
|
+
if isinstance(content, list):
|
|
2492
|
+
for block in content:
|
|
2493
|
+
if not isinstance(block, dict):
|
|
2494
|
+
continue
|
|
2495
|
+
if block.get("type") != "tool_result":
|
|
2496
|
+
continue
|
|
2497
|
+
tool_name = tool_name_by_id.get(
|
|
2498
|
+
str(block.get("tool_use_id", "")),
|
|
2499
|
+
"unknown",
|
|
2500
|
+
)
|
|
2501
|
+
ui_console.print_tool_result(tool_name, block.get("content", ""))
|
|
2502
|
+
continue
|
|
2503
|
+
|
|
2504
|
+
if role != "assistant":
|
|
2505
|
+
continue
|
|
2506
|
+
|
|
2507
|
+
if isinstance(content, str):
|
|
2508
|
+
ui_console.print_assistant(content)
|
|
2509
|
+
continue
|
|
2510
|
+
if not isinstance(content, list):
|
|
2511
|
+
continue
|
|
2512
|
+
|
|
2513
|
+
text_parts: list[str] = []
|
|
2514
|
+
for block in content:
|
|
2515
|
+
if not isinstance(block, dict):
|
|
2516
|
+
continue
|
|
2517
|
+
if block.get("type") == "text":
|
|
2518
|
+
text_value = str(block.get("text", ""))
|
|
2519
|
+
if text_value:
|
|
2520
|
+
text_parts.append(text_value)
|
|
2521
|
+
continue
|
|
2522
|
+
if block.get("type") != "tool_use":
|
|
2523
|
+
continue
|
|
2524
|
+
|
|
2525
|
+
tool_name = str(block.get("name", "unknown"))
|
|
2526
|
+
tool_id = str(block.get("id", ""))
|
|
2527
|
+
if tool_id:
|
|
2528
|
+
tool_name_by_id[tool_id] = tool_name
|
|
2529
|
+
|
|
2530
|
+
if text_parts:
|
|
2531
|
+
ui_console.print_assistant("\n".join(text_parts))
|
|
2532
|
+
text_parts = []
|
|
2533
|
+
ui_console.print_tool_call(tool_name, block.get("input", {}))
|
|
2534
|
+
|
|
2535
|
+
if text_parts:
|
|
2536
|
+
ui_console.print_assistant("\n".join(text_parts))
|
|
2537
|
+
|
|
2538
|
+
|
|
2539
|
+
def _handle_mode_selection_stdio(
|
|
2540
|
+
permission: PermissionGuard,
|
|
2541
|
+
ui_console: AgentConsole,
|
|
2542
|
+
) -> None:
|
|
2543
|
+
lines = ["Permission modes:"]
|
|
2544
|
+
for idx, mode in enumerate(_MODE_CYCLE, 1):
|
|
2545
|
+
marker = "*" if mode == permission.mode else " "
|
|
2546
|
+
lines.append(f" {marker} {idx}) {mode.value:<10} {_MODE_DESCRIPTIONS[mode]}")
|
|
2547
|
+
ui_console.print_status("\n".join(lines))
|
|
2548
|
+
ui_console.print_status(f"Select [1-{len(_MODE_CYCLE)}] on the next prompt.")
|
|
2549
|
+
valid_choices = {str(i) for i in range(1, len(_MODE_CYCLE) + 1)}
|
|
2550
|
+
try:
|
|
2551
|
+
choice = _read_stdio_input().strip()
|
|
2552
|
+
except (EOFError, KeyboardInterrupt):
|
|
2553
|
+
ui_console.print_status("Mode selection cancelled.")
|
|
2554
|
+
return
|
|
2555
|
+
|
|
2556
|
+
if choice in valid_choices:
|
|
2557
|
+
old = permission.mode
|
|
2558
|
+
permission.mode = _MODE_CYCLE[int(choice) - 1]
|
|
2559
|
+
ui_console.print_status(f"Permission mode: {old.value} → {permission.mode.value}")
|
|
2560
|
+
return
|
|
2561
|
+
|
|
2562
|
+
ui_console.print_status("Invalid choice, mode unchanged.")
|
|
2563
|
+
|
|
2564
|
+
|
|
2565
|
+
def _make_plan_approval(
|
|
2566
|
+
permission: PermissionGuard,
|
|
2567
|
+
ui_console: AgentConsole,
|
|
2568
|
+
) -> Callable[[str], PlanDecision]:
|
|
2569
|
+
"""Build the ``exit_plan_mode`` approval callback.
|
|
2570
|
+
|
|
2571
|
+
Renders the proposed plan, then prompts the user for a three-way decision
|
|
2572
|
+
(approve -> DEFAULT, approve+auto -> AUTO, reject -> stay in PLAN). On reject
|
|
2573
|
+
it collects an optional free-text reason fed back to the model. This wiring
|
|
2574
|
+
layer owns the permission-mode flip because it holds both the
|
|
2575
|
+
``PermissionGuard`` and the UI console; the handler stays pure.
|
|
2576
|
+
"""
|
|
2577
|
+
|
|
2578
|
+
def _approve(plan: str) -> PlanDecision:
|
|
2579
|
+
# Defensive: the directive is injected only in PLAN mode, so a
|
|
2580
|
+
# well-behaved model won't call exit_plan_mode otherwise. If it does,
|
|
2581
|
+
# report a no-op rather than silently flipping an unrelated mode.
|
|
2582
|
+
if permission.mode != PermissionMode.PLAN:
|
|
2583
|
+
return PlanDecision("noop")
|
|
2584
|
+
# Approving a plan elevates the permission mode, which is a form of
|
|
2585
|
+
# approval -- a fail-closed guard must never grant it (error-handling.md).
|
|
2586
|
+
# This path only ever holds the main guard (fail_closed=False), but the
|
|
2587
|
+
# check keeps the invariant if the wiring ever changes.
|
|
2588
|
+
if getattr(permission, "fail_closed", False) or not sys.stdin.isatty():
|
|
2589
|
+
return PlanDecision("unavailable")
|
|
2590
|
+
|
|
2591
|
+
ui_console.print_status("Proposed plan:")
|
|
2592
|
+
ui_console.print_assistant(plan)
|
|
2593
|
+
ui_console.print_status(
|
|
2594
|
+
"Approve this plan?\n"
|
|
2595
|
+
" 1) Approve -- switch to DEFAULT (writes still confirmed)\n"
|
|
2596
|
+
" 2) Approve & auto-accept -- switch to AUTO\n"
|
|
2597
|
+
" 3) Reject -- stay in plan mode and revise"
|
|
2598
|
+
)
|
|
2599
|
+
try:
|
|
2600
|
+
choice = _read_stdio_input().strip()
|
|
2601
|
+
except (EOFError, KeyboardInterrupt):
|
|
2602
|
+
ui_console.print_status("Plan approval cancelled; staying in plan mode.")
|
|
2603
|
+
return PlanDecision("reject")
|
|
2604
|
+
|
|
2605
|
+
if choice == "1":
|
|
2606
|
+
_switch_mode_after_approval(permission, ui_console, PermissionMode.DEFAULT)
|
|
2607
|
+
return PlanDecision("approve-default")
|
|
2608
|
+
if choice == "2":
|
|
2609
|
+
_switch_mode_after_approval(permission, ui_console, PermissionMode.AUTO)
|
|
2610
|
+
return PlanDecision("approve-auto")
|
|
2611
|
+
|
|
2612
|
+
# Anything else counts as reject. Collect an optional reason so the model
|
|
2613
|
+
# can revise with guidance instead of guessing.
|
|
2614
|
+
ui_console.print_status("Plan rejected. Optionally enter a reason (blank to skip):")
|
|
2615
|
+
try:
|
|
2616
|
+
reason = _read_stdio_input().strip()
|
|
2617
|
+
except (EOFError, KeyboardInterrupt):
|
|
2618
|
+
reason = ""
|
|
2619
|
+
return PlanDecision("reject", reason)
|
|
2620
|
+
|
|
2621
|
+
return _approve
|
|
2622
|
+
|
|
2623
|
+
|
|
2624
|
+
def _switch_mode_after_approval(
|
|
2625
|
+
permission: PermissionGuard,
|
|
2626
|
+
ui_console: AgentConsole,
|
|
2627
|
+
new_mode: PermissionMode,
|
|
2628
|
+
) -> None:
|
|
2629
|
+
old = permission.mode
|
|
2630
|
+
permission.mode = new_mode
|
|
2631
|
+
ui_console.print_status(f"Permission mode: {old.value} -> {new_mode.value}")
|
|
2632
|
+
|
|
2633
|
+
|
|
2634
|
+
def _install_plan_handler(
|
|
2635
|
+
handlers: dict[str, Any],
|
|
2636
|
+
plan_approval: Callable[[str], PlanDecision],
|
|
2637
|
+
) -> None:
|
|
2638
|
+
"""Register the exit_plan_mode handler on a (re)built handler dict.
|
|
2639
|
+
|
|
2640
|
+
Called after every ``_build_handlers`` in the main loop -- session switches
|
|
2641
|
+
(/new, /compact, /resume, /import) rebuild ``handlers`` from scratch and
|
|
2642
|
+
would otherwise drop the main-loop-only exit_plan_mode handler, leaving its
|
|
2643
|
+
schema in ``tools`` with no handler behind it.
|
|
2644
|
+
"""
|
|
2645
|
+
handlers["exit_plan_mode"] = partial(run_exit_plan_mode, approve_fn=plan_approval)
|
|
2646
|
+
|
|
2647
|
+
|
|
2648
|
+
def _run_node_batch(
|
|
2649
|
+
thunks: list[Callable[[], NodeResult]],
|
|
2650
|
+
max_concurrency: int,
|
|
2651
|
+
) -> list[NodeResult]:
|
|
2652
|
+
"""Run a batch of (never-raising) node thunks concurrently, preserving order.
|
|
2653
|
+
|
|
2654
|
+
A single node skips the thread-pool overhead. ``KeyboardInterrupt`` (delivered
|
|
2655
|
+
to this main thread while blocked on results) cancels pending nodes and tears
|
|
2656
|
+
the executor down without waiting, then propagates so the workflow tool call
|
|
2657
|
+
aborts cleanly (in-flight nodes finish in the background). Worker threads only
|
|
2658
|
+
run ``run_subagent`` (which is silent -- no console / messages), mirroring how
|
|
2659
|
+
the ``/loop`` scheduler keeps execution off the REPL-owned UI thread.
|
|
2660
|
+
"""
|
|
2661
|
+
if not thunks:
|
|
2662
|
+
return []
|
|
2663
|
+
if len(thunks) == 1:
|
|
2664
|
+
return [thunks[0]()]
|
|
2665
|
+
|
|
2666
|
+
workers = max(1, min(max_concurrency, len(thunks)))
|
|
2667
|
+
executor = ThreadPoolExecutor(max_workers=workers, thread_name_prefix="wf-node")
|
|
2668
|
+
futures = [executor.submit(thunk) for thunk in thunks]
|
|
2669
|
+
try:
|
|
2670
|
+
return [future.result() for future in futures]
|
|
2671
|
+
except KeyboardInterrupt:
|
|
2672
|
+
for future in futures:
|
|
2673
|
+
future.cancel()
|
|
2674
|
+
executor.shutdown(wait=False, cancel_futures=True)
|
|
2675
|
+
raise
|
|
2676
|
+
finally:
|
|
2677
|
+
executor.shutdown(wait=False)
|
|
2678
|
+
|
|
2679
|
+
|
|
2680
|
+
def _install_workflow_handler(
|
|
2681
|
+
handlers: dict[str, Any],
|
|
2682
|
+
*,
|
|
2683
|
+
enabled: bool,
|
|
2684
|
+
provider: BaseLLMProvider,
|
|
2685
|
+
base_tools: list[dict[str, Any]],
|
|
2686
|
+
permission: PermissionGuard,
|
|
2687
|
+
bg_manager: BackgroundManager,
|
|
2688
|
+
console: AgentConsole,
|
|
2689
|
+
retry_policy: RetryPolicy,
|
|
2690
|
+
max_depth: int,
|
|
2691
|
+
default_agent_type: str,
|
|
2692
|
+
max_concurrency: int,
|
|
2693
|
+
max_nodes: int,
|
|
2694
|
+
registry: WorkflowRegistry,
|
|
2695
|
+
default_token_budget: int,
|
|
2696
|
+
) -> None:
|
|
2697
|
+
"""Install the main-loop-only ``workflow`` handler on a (re)built handler dict.
|
|
2698
|
+
|
|
2699
|
+
Like ``_install_plan_handler``, this is re-run after every ``_build_handlers``
|
|
2700
|
+
(session switches rebuild ``handlers`` from scratch). It binds the node
|
|
2701
|
+
executor to *this* handler dict so nodes inherit the current tools/handlers;
|
|
2702
|
+
``run_subagent`` filters the main-loop-only tools (``workflow`` itself,
|
|
2703
|
+
``exit_plan_mode``) back out per node. Disabled config -> no install (the
|
|
2704
|
+
schema is also withheld from ``loop_tools``), so the feature fully short
|
|
2705
|
+
-circuits. Node subagents run fail-closed (no prompts from worker threads),
|
|
2706
|
+
the same unattended stance as background subagents and ``/loop``.
|
|
2707
|
+
|
|
2708
|
+
Each call registers a :class:`WorkflowRun` so the ``/workflows`` panel and
|
|
2709
|
+
``resume`` can see it. ``run_in_background`` runs the whole DAG in a daemon
|
|
2710
|
+
thread (the result is injected later by ``_drain_workflow_results``);
|
|
2711
|
+
``resume_from`` reuses a prior run's unchanged nodes; ``token_budget`` (with
|
|
2712
|
+
the per-node ``TokenTracker``s summed under a lock) caps the run at layer
|
|
2713
|
+
boundaries.
|
|
2714
|
+
"""
|
|
2715
|
+
if not enabled:
|
|
2716
|
+
return
|
|
2717
|
+
node_handlers = handlers
|
|
2718
|
+
|
|
2719
|
+
def handler(**kwargs: Any) -> str:
|
|
2720
|
+
validated = validate_workflow_input(kwargs.get("nodes"), max_nodes=max_nodes)
|
|
2721
|
+
if isinstance(validated, str):
|
|
2722
|
+
return validated # immediate feedback for a malformed DAG (sync or bg)
|
|
2723
|
+
spec = validated
|
|
2724
|
+
|
|
2725
|
+
tool_budget = kwargs.get("token_budget")
|
|
2726
|
+
valid_budget = (
|
|
2727
|
+
isinstance(tool_budget, int) and not isinstance(tool_budget, bool) and tool_budget > 0
|
|
2728
|
+
)
|
|
2729
|
+
effective_budget = int(tool_budget) if valid_budget else default_token_budget
|
|
2730
|
+
resume_from = kwargs.get("resume_from")
|
|
2731
|
+
background = bool(kwargs.get("run_in_background"))
|
|
2732
|
+
|
|
2733
|
+
run_id = registry.generate_id()
|
|
2734
|
+
registry.start(run_id, spec, background=background, token_budget=effective_budget)
|
|
2735
|
+
|
|
2736
|
+
# Per-run token accounting. Each node uses its OWN TokenTracker (thread
|
|
2737
|
+
# -local, no shared mutation); its total is folded into a locked
|
|
2738
|
+
# accumulator after the node returns, so the layer-boundary budget check
|
|
2739
|
+
# reads a consistent figure even with nodes running concurrently.
|
|
2740
|
+
budget_lock = threading.Lock()
|
|
2741
|
+
spent = [0]
|
|
2742
|
+
|
|
2743
|
+
def tokens_spent() -> int:
|
|
2744
|
+
with budget_lock:
|
|
2745
|
+
return spent[0]
|
|
2746
|
+
|
|
2747
|
+
def execute_node(node: WorkflowNode, upstream: dict[str, NodeResult]) -> str:
|
|
2748
|
+
node_permission: Any = permission
|
|
2749
|
+
if isinstance(permission, PermissionGuard):
|
|
2750
|
+
node_permission = permission.clone(fail_closed=True)
|
|
2751
|
+
node_tracker = TokenTracker()
|
|
2752
|
+
output = run_subagent(
|
|
2753
|
+
provider=provider,
|
|
2754
|
+
task=build_node_prompt(node, upstream),
|
|
2755
|
+
tools=base_tools,
|
|
2756
|
+
handlers=node_handlers,
|
|
2757
|
+
permission=node_permission,
|
|
2758
|
+
max_depth=max_depth,
|
|
2759
|
+
agent_type=node.agent_type,
|
|
2760
|
+
bg_manager=bg_manager,
|
|
2761
|
+
default_agent_type=default_agent_type,
|
|
2762
|
+
retry_policy=retry_policy,
|
|
2763
|
+
token_tracker=node_tracker,
|
|
2764
|
+
)
|
|
2765
|
+
with budget_lock:
|
|
2766
|
+
spent[0] += node_tracker.total_tokens
|
|
2767
|
+
current = spent[0]
|
|
2768
|
+
registry.set_tokens(run_id, current)
|
|
2769
|
+
return output
|
|
2770
|
+
|
|
2771
|
+
# Background nodes run off the REPL thread, so progress must NOT touch the
|
|
2772
|
+
# console (thread-safety rule, see Scheduler); the panel reflects status
|
|
2773
|
+
# instead. Sync runs keep the live console progress.
|
|
2774
|
+
progress = (lambda _message: None) if background else console.print_status
|
|
2775
|
+
|
|
2776
|
+
def drive() -> str:
|
|
2777
|
+
summary = run_workflow_tool(
|
|
2778
|
+
spec=spec,
|
|
2779
|
+
resume_from=resume_from if isinstance(resume_from, str) else None,
|
|
2780
|
+
token_budget=effective_budget,
|
|
2781
|
+
execute_node=execute_node,
|
|
2782
|
+
map_concurrent=lambda thunks: _run_node_batch(thunks, max_concurrency),
|
|
2783
|
+
on_progress=progress,
|
|
2784
|
+
on_node_status=lambda node_id, result: registry.update_node(
|
|
2785
|
+
run_id, node_id, result
|
|
2786
|
+
),
|
|
2787
|
+
resolve_prior=registry.get_for_resume,
|
|
2788
|
+
tokens_spent=tokens_spent,
|
|
2789
|
+
max_nodes=max_nodes,
|
|
2790
|
+
)
|
|
2791
|
+
registry.finish(run_id, summary=summary, tokens_spent=tokens_spent())
|
|
2792
|
+
return summary
|
|
2793
|
+
|
|
2794
|
+
if background:
|
|
2795
|
+
|
|
2796
|
+
def background_runner() -> str:
|
|
2797
|
+
try:
|
|
2798
|
+
return drive()
|
|
2799
|
+
except Exception as exc: # noqa: BLE001 - surface, don't lose, a bg crash
|
|
2800
|
+
registry.finish(
|
|
2801
|
+
run_id,
|
|
2802
|
+
summary=f"Error: workflow crashed: {type(exc).__name__}: {exc}",
|
|
2803
|
+
tokens_spent=tokens_spent(),
|
|
2804
|
+
status=RunStatus.FAILED,
|
|
2805
|
+
error=f"{type(exc).__name__}: {exc}",
|
|
2806
|
+
)
|
|
2807
|
+
return ""
|
|
2808
|
+
|
|
2809
|
+
bg_manager.submit(run_id, background_runner)
|
|
2810
|
+
return (
|
|
2811
|
+
f"Workflow {run_id} started in the background ({len(spec.nodes)} node(s)). "
|
|
2812
|
+
"Watch it with /workflows; its result will be delivered when it finishes."
|
|
2813
|
+
)
|
|
2814
|
+
|
|
2815
|
+
return drive() + f"\n\n[workflow run id: {run_id}]"
|
|
2816
|
+
|
|
2817
|
+
handlers["workflow"] = handler
|
|
2818
|
+
|
|
2819
|
+
|
|
2820
|
+
def _install_subagent_send_handler(
|
|
2821
|
+
handlers: dict[str, Any],
|
|
2822
|
+
*,
|
|
2823
|
+
registry: SubagentRegistry,
|
|
2824
|
+
) -> None:
|
|
2825
|
+
"""Install the main-loop-only ``subagent_send`` handler on a (re)built dict.
|
|
2826
|
+
|
|
2827
|
+
Like ``_install_workflow_handler`` / ``_install_plan_handler``, this re-runs
|
|
2828
|
+
after every ``_build_handlers`` (session switches rebuild ``handlers`` from
|
|
2829
|
+
scratch). Everything needed to re-enter ``agent_loop`` lives in the stored
|
|
2830
|
+
``ResumableContext`` (provider / tools / handlers / permission / compactor /
|
|
2831
|
+
turn budget / retry policy), so the closure only needs the registry. The
|
|
2832
|
+
schema is also kept out of the base ``tools`` and listed in
|
|
2833
|
+
``MAIN_LOOP_ONLY_TOOLS``, so no sub-agent ever sees this tool.
|
|
2834
|
+
"""
|
|
2835
|
+
|
|
2836
|
+
def run_loop(context: ResumableContext) -> str:
|
|
2837
|
+
return agent_loop(
|
|
2838
|
+
provider=context.provider,
|
|
2839
|
+
messages=context.messages,
|
|
2840
|
+
tools=context.tools,
|
|
2841
|
+
handlers=context.handlers,
|
|
2842
|
+
permission=context.permission,
|
|
2843
|
+
compact_fn=context.compact_fn,
|
|
2844
|
+
bg_manager=None,
|
|
2845
|
+
max_iterations=context.max_turns,
|
|
2846
|
+
retry_policy=context.retry_policy,
|
|
2847
|
+
)
|
|
2848
|
+
|
|
2849
|
+
def handler(agent_id: str = "", message: str = "", **_: Any) -> str:
|
|
2850
|
+
return run_subagent_send(
|
|
2851
|
+
agent_id,
|
|
2852
|
+
message,
|
|
2853
|
+
registry=registry,
|
|
2854
|
+
run_loop=run_loop,
|
|
2855
|
+
)
|
|
2856
|
+
|
|
2857
|
+
handlers["subagent_send"] = handler
|
|
2858
|
+
|
|
2859
|
+
|
|
2860
|
+
def _install_mcp_cleanup(mcp_manager: MCPManager) -> None:
|
|
2861
|
+
"""Register exit-time + SIGTERM hooks so MCP subprocesses are reaped.
|
|
2862
|
+
|
|
2863
|
+
``atexit`` catches ``return`` from ``main()`` and any ``sys.exit()``;
|
|
2864
|
+
the SIGTERM handler converts a polite termination into ``sys.exit(130)``
|
|
2865
|
+
so ``atexit`` actually fires (raw SIGTERM bypasses it). SIGINT is
|
|
2866
|
+
intentionally left alone — prompt-toolkit + the existing
|
|
2867
|
+
``KeyboardInterrupt`` handling in the REPL loop already cover Ctrl+C.
|
|
2868
|
+
"""
|
|
2869
|
+
atexit.register(mcp_manager.close_all)
|
|
2870
|
+
try:
|
|
2871
|
+
signal.signal(signal.SIGTERM, lambda *_: sys.exit(130))
|
|
2872
|
+
except (ValueError, OSError): # pragma: no cover — non-main thread / unsupported OS
|
|
2873
|
+
pass
|
|
2874
|
+
|
|
2875
|
+
|
|
2876
|
+
def _install_lsp_cleanup(lsp_manager: LanguageServerManager) -> None:
|
|
2877
|
+
"""Register an idempotent atexit hook for the LSP manager.
|
|
2878
|
+
|
|
2879
|
+
Deliberately decoupled from :func:`_install_mcp_cleanup`: ``atexit`` fires
|
|
2880
|
+
callbacks LIFO regardless of registration order, and
|
|
2881
|
+
``lsp_manager.close_all`` is guaranteed idempotent (see manager docstring)
|
|
2882
|
+
so duplicate registration from a future caller is safe. The SIGTERM
|
|
2883
|
+
handler is already installed by the MCP path; we rely on that to convert
|
|
2884
|
+
the signal into ``sys.exit(130)`` so this atexit hook actually fires.
|
|
2885
|
+
"""
|
|
2886
|
+
atexit.register(lsp_manager.close_all)
|
|
2887
|
+
|
|
2888
|
+
|
|
2889
|
+
_LSP_COMMAND_USAGE = "Usage: /lsp <status|list|reload <language>>"
|
|
2890
|
+
|
|
2891
|
+
|
|
2892
|
+
def _dispatch_lsp_command(
|
|
2893
|
+
text: str,
|
|
2894
|
+
*,
|
|
2895
|
+
lsp_manager: LanguageServerManager,
|
|
2896
|
+
ui_console: AgentConsole,
|
|
2897
|
+
) -> None:
|
|
2898
|
+
"""Handle the space-prefixed ``/lsp <subcommand>`` REPL command.
|
|
2899
|
+
|
|
2900
|
+
Mirrors the ``/mcp`` command shape: ``status`` shows per-server health,
|
|
2901
|
+
``list`` enumerates the ``lsp_*`` tools available right now (only for
|
|
2902
|
+
RUNNING servers), and ``reload <language>`` rebuilds one server.
|
|
2903
|
+
Feedback flows through ``ui_console``; the routine never raises.
|
|
2904
|
+
"""
|
|
2905
|
+
tokens = text.split()
|
|
2906
|
+
if len(tokens) <= 1:
|
|
2907
|
+
ui_console.print_status(_LSP_COMMAND_USAGE)
|
|
2908
|
+
return
|
|
2909
|
+
sub = tokens[1]
|
|
2910
|
+
if sub == "status":
|
|
2911
|
+
rows = lsp_manager.summarize()
|
|
2912
|
+
if not rows:
|
|
2913
|
+
ui_console.print_status("(no LSP servers configured)")
|
|
2914
|
+
return
|
|
2915
|
+
for row in rows:
|
|
2916
|
+
extensions = ", ".join(row["extensions"]) or "-"
|
|
2917
|
+
line = (
|
|
2918
|
+
f"{row['language']}: {row['status']} [{row['tool_count']} tools, ext={extensions}]"
|
|
2919
|
+
)
|
|
2920
|
+
reason = row.get("reason") or ""
|
|
2921
|
+
if reason:
|
|
2922
|
+
line = f"{line} — {reason}"
|
|
2923
|
+
ui_console.print_status(line)
|
|
2924
|
+
return
|
|
2925
|
+
if sub == "list":
|
|
2926
|
+
any_server = False
|
|
2927
|
+
for language, _server in lsp_manager.iter_running():
|
|
2928
|
+
any_server = True
|
|
2929
|
+
ui_console.print_status(f"[{language}]")
|
|
2930
|
+
# The four Tier-1 tools are uniform across servers; list them so
|
|
2931
|
+
# users see exactly what the LLM has access to right now.
|
|
2932
|
+
for tool in (
|
|
2933
|
+
"lsp_outline",
|
|
2934
|
+
"lsp_definition",
|
|
2935
|
+
"lsp_references",
|
|
2936
|
+
"lsp_diagnostics",
|
|
2937
|
+
):
|
|
2938
|
+
ui_console.print_status(f" {tool}")
|
|
2939
|
+
if not any_server:
|
|
2940
|
+
ui_console.print_status("(no LSP servers running)")
|
|
2941
|
+
return
|
|
2942
|
+
if sub == "reload":
|
|
2943
|
+
if len(tokens) < 3:
|
|
2944
|
+
ui_console.print_error("Usage: /lsp reload <language>")
|
|
2945
|
+
return
|
|
2946
|
+
target = tokens[2]
|
|
2947
|
+
try:
|
|
2948
|
+
lsp_manager.reload(target)
|
|
2949
|
+
except LSPError as exc:
|
|
2950
|
+
ui_console.print_error(f"reload {target!r} failed: {exc}")
|
|
2951
|
+
ui_console.print_error(f"LSP server {target!r} is now UNHEALTHY.")
|
|
2952
|
+
return
|
|
2953
|
+
except Exception as exc:
|
|
2954
|
+
ui_console.print_error(f"reload {target!r} failed: {exc}")
|
|
2955
|
+
ui_console.print_error(f"LSP server {target!r} is now UNHEALTHY.")
|
|
2956
|
+
return
|
|
2957
|
+
ui_console.print_status(f"LSP server {target!r} reloaded.")
|
|
2958
|
+
return
|
|
2959
|
+
ui_console.print_error(f"Unknown /lsp subcommand: {sub}. Use status, list, or reload.")
|
|
2960
|
+
|
|
2961
|
+
|
|
2962
|
+
_LOOP_COMMAND_USAGE = (
|
|
2963
|
+
"Usage:\n"
|
|
2964
|
+
" /loop <seconds> <command...> Schedule a command to repeat every N seconds\n"
|
|
2965
|
+
" /loop list List scheduled commands\n"
|
|
2966
|
+
" /loop cancel <job_id> Cancel one scheduled command\n"
|
|
2967
|
+
" /loop clear Cancel all scheduled commands\n"
|
|
2968
|
+
"Note: scheduled commands run WITHOUT permission prompts (no human to confirm)."
|
|
2969
|
+
)
|
|
2970
|
+
|
|
2971
|
+
|
|
2972
|
+
def _dispatch_loop_command(
|
|
2973
|
+
text: str,
|
|
2974
|
+
*,
|
|
2975
|
+
scheduler: Scheduler,
|
|
2976
|
+
ui_console: AgentConsole,
|
|
2977
|
+
) -> None:
|
|
2978
|
+
"""Handle the ``/loop`` REPL command for the cron-style scheduler.
|
|
2979
|
+
|
|
2980
|
+
Forms: ``/loop`` / ``/loop list`` (list), ``/loop <seconds> <command...>``
|
|
2981
|
+
(create), ``/loop cancel <job_id>`` (cancel one), ``/loop clear`` (cancel
|
|
2982
|
+
all). Scheduled commands run via the background pool WITHOUT permission
|
|
2983
|
+
confirmation, so the create path echoes that warning. Never raises.
|
|
2984
|
+
"""
|
|
2985
|
+
rest = text[len("/loop") :].strip()
|
|
2986
|
+
if not rest or rest == "list":
|
|
2987
|
+
jobs = scheduler.list()
|
|
2988
|
+
if not jobs:
|
|
2989
|
+
ui_console.print_status(f"(no scheduled commands)\n{_LOOP_COMMAND_USAGE}")
|
|
2990
|
+
return
|
|
2991
|
+
for job in jobs:
|
|
2992
|
+
ui_console.print_status(
|
|
2993
|
+
f"{job.job_id}: every {job.interval_sec:g}s, runs={job.run_count} — {job.command}"
|
|
2994
|
+
)
|
|
2995
|
+
return
|
|
2996
|
+
|
|
2997
|
+
first, _, remainder = rest.partition(" ")
|
|
2998
|
+
if first == "cancel":
|
|
2999
|
+
job_id = remainder.strip()
|
|
3000
|
+
if not job_id:
|
|
3001
|
+
ui_console.print_error("Usage: /loop cancel <job_id>")
|
|
3002
|
+
return
|
|
3003
|
+
if scheduler.cancel(job_id):
|
|
3004
|
+
ui_console.print_status(f"Cancelled scheduled command: {job_id}")
|
|
3005
|
+
else:
|
|
3006
|
+
ui_console.print_error(f"No scheduled command found: {job_id}")
|
|
3007
|
+
return
|
|
3008
|
+
if first == "clear":
|
|
3009
|
+
scheduler.cancel_all()
|
|
3010
|
+
ui_console.print_status("Cancelled all scheduled commands.")
|
|
3011
|
+
return
|
|
3012
|
+
|
|
3013
|
+
# Create form: first token is the interval in seconds, the rest is the
|
|
3014
|
+
# command verbatim (keep internal spaces).
|
|
3015
|
+
try:
|
|
3016
|
+
interval_sec = float(first)
|
|
3017
|
+
except ValueError:
|
|
3018
|
+
ui_console.print_error(
|
|
3019
|
+
f"Invalid interval {first!r}: expected a number of seconds.\n{_LOOP_COMMAND_USAGE}"
|
|
3020
|
+
)
|
|
3021
|
+
return
|
|
3022
|
+
command = remainder.strip()
|
|
3023
|
+
if not command:
|
|
3024
|
+
ui_console.print_error(f"Missing command to schedule.\n{_LOOP_COMMAND_USAGE}")
|
|
3025
|
+
return
|
|
3026
|
+
try:
|
|
3027
|
+
job = scheduler.add(interval_sec, command)
|
|
3028
|
+
except SchedulerError as exc:
|
|
3029
|
+
ui_console.print_error(str(exc))
|
|
3030
|
+
return
|
|
3031
|
+
ui_console.print_status(
|
|
3032
|
+
f"Scheduled {job.job_id}: every {job.interval_sec:g}s — {job.command}\n"
|
|
3033
|
+
"Warning: this command runs WITHOUT permission confirmation in the background."
|
|
3034
|
+
)
|
|
3035
|
+
|
|
3036
|
+
|
|
3037
|
+
_WORKFLOWS_COMMAND_USAGE = "Usage: /workflows [list] | /workflows <run-id> | /workflows clear"
|
|
3038
|
+
|
|
3039
|
+
|
|
3040
|
+
def _humanize_age(seconds: float) -> str:
|
|
3041
|
+
"""Render an elapsed-seconds duration compactly (e.g. ``5s``, ``3m``, ``1h2m``)."""
|
|
3042
|
+
seconds = max(0, int(seconds))
|
|
3043
|
+
if seconds < 60:
|
|
3044
|
+
return f"{seconds}s"
|
|
3045
|
+
minutes, sec = divmod(seconds, 60)
|
|
3046
|
+
if minutes < 60:
|
|
3047
|
+
return f"{minutes}m{sec}s" if sec else f"{minutes}m"
|
|
3048
|
+
hours, minutes = divmod(minutes, 60)
|
|
3049
|
+
return f"{hours}h{minutes}m" if minutes else f"{hours}h"
|
|
3050
|
+
|
|
3051
|
+
|
|
3052
|
+
def _format_workflow_run_line(run: WorkflowRun, now: float) -> str:
|
|
3053
|
+
"""One-line panel summary of a run (pure; ``now`` injected for testability)."""
|
|
3054
|
+
counts = run.counts()
|
|
3055
|
+
parts = [f"{counts['done']} done"]
|
|
3056
|
+
if counts["reused"]:
|
|
3057
|
+
parts.append(f"{counts['reused']} reused")
|
|
3058
|
+
if counts["failed"]:
|
|
3059
|
+
parts.append(f"{counts['failed']} failed")
|
|
3060
|
+
if counts["skipped"]:
|
|
3061
|
+
parts.append(f"{counts['skipped']} skipped")
|
|
3062
|
+
if counts["running"]:
|
|
3063
|
+
parts.append(f"{counts['running']} running")
|
|
3064
|
+
total = len(run.spec.nodes)
|
|
3065
|
+
budget = f"/{run.token_budget}" if run.token_budget else ""
|
|
3066
|
+
flag = " [bg]" if run.background else ""
|
|
3067
|
+
age = _humanize_age(now - run.started_at)
|
|
3068
|
+
return (
|
|
3069
|
+
f"{run.run_id}: {run.status.value}{flag} — {total} node(s) "
|
|
3070
|
+
f"[{', '.join(parts)}], {run.tokens_spent}{budget} tok, {age} ago"
|
|
3071
|
+
)
|
|
3072
|
+
|
|
3073
|
+
|
|
3074
|
+
def _format_workflow_run_detail(run: WorkflowRun, now: float, *, preview: int = 200) -> str:
|
|
3075
|
+
"""Multi-line per-node detail of one run (pure; ``now`` injected)."""
|
|
3076
|
+
lines = [_format_workflow_run_line(run, now)]
|
|
3077
|
+
for node in run.spec.nodes:
|
|
3078
|
+
result = run.results.get(node.id)
|
|
3079
|
+
if result is None:
|
|
3080
|
+
continue
|
|
3081
|
+
marker = "reused" if result.reused else result.status.value
|
|
3082
|
+
head = f" [{marker}] {node.id}"
|
|
3083
|
+
if node.phase:
|
|
3084
|
+
head += f" (phase: {node.phase})"
|
|
3085
|
+
if node.label:
|
|
3086
|
+
head += f" - {node.label}"
|
|
3087
|
+
lines.append(head)
|
|
3088
|
+
body = (result.output if result.status is NodeStatus.DONE else result.error).strip()
|
|
3089
|
+
if body:
|
|
3090
|
+
snippet = body[:preview] + ("..." if len(body) > preview else "")
|
|
3091
|
+
lines.append(f" {snippet}")
|
|
3092
|
+
return "\n".join(lines)
|
|
3093
|
+
|
|
3094
|
+
|
|
3095
|
+
def _dispatch_workflows_command(
|
|
3096
|
+
text: str,
|
|
3097
|
+
*,
|
|
3098
|
+
registry: WorkflowRegistry,
|
|
3099
|
+
ui_console: AgentConsole,
|
|
3100
|
+
) -> None:
|
|
3101
|
+
"""Handle the ``/workflows`` REPL command (panel; never raises).
|
|
3102
|
+
|
|
3103
|
+
Forms: ``/workflows`` / ``/workflows list`` (list this session's runs),
|
|
3104
|
+
``/workflows <run-id>`` (per-node detail), ``/workflows clear`` (drop finished
|
|
3105
|
+
runs). Read-only over the in-memory registry -- no LLM, no permission gate.
|
|
3106
|
+
"""
|
|
3107
|
+
rest = text[len("/workflows") :].strip()
|
|
3108
|
+
now = time.time()
|
|
3109
|
+
if not rest or rest == "list":
|
|
3110
|
+
runs = registry.snapshot()
|
|
3111
|
+
if not runs:
|
|
3112
|
+
ui_console.print_status(f"(no workflow runs)\n{_WORKFLOWS_COMMAND_USAGE}")
|
|
3113
|
+
return
|
|
3114
|
+
for run in runs:
|
|
3115
|
+
ui_console.print_status(_format_workflow_run_line(run, now))
|
|
3116
|
+
return
|
|
3117
|
+
if rest == "clear":
|
|
3118
|
+
removed = registry.clear_finished()
|
|
3119
|
+
ui_console.print_status(f"Cleared {removed} finished workflow run(s).")
|
|
3120
|
+
return
|
|
3121
|
+
run = registry.get(rest)
|
|
3122
|
+
if run is None:
|
|
3123
|
+
ui_console.print_error(f"No workflow run found: {rest}\n{_WORKFLOWS_COMMAND_USAGE}")
|
|
3124
|
+
return
|
|
3125
|
+
ui_console.print_status(_format_workflow_run_detail(run, now))
|
|
3126
|
+
|
|
3127
|
+
|
|
3128
|
+
def _run_skill_reflection(
|
|
3129
|
+
*,
|
|
3130
|
+
provider: Any,
|
|
3131
|
+
messages: list[dict[str, Any]],
|
|
3132
|
+
store: SkillStore,
|
|
3133
|
+
skill_loader: SkillLoader,
|
|
3134
|
+
console: AgentConsole,
|
|
3135
|
+
token_tracker: Any,
|
|
3136
|
+
permission: Any,
|
|
3137
|
+
max_pending: int,
|
|
3138
|
+
) -> None:
|
|
3139
|
+
"""Reflect on the just-finished session and draft (or evolve) a skill.
|
|
3140
|
+
|
|
3141
|
+
Runs an isolated ``agent_loop`` on a COPY of the conversation. ``skill_create``
|
|
3142
|
+
is the only write tool, so the real history / turn result stay clean and
|
|
3143
|
+
normal turns / sub-agents never see it. When generated skills already exist,
|
|
3144
|
+
the reflection additionally lists them as refinement targets and exposes
|
|
3145
|
+
read-only ``load_skill`` so the model can read one and supersede it with an
|
|
3146
|
+
improved same-name draft (self-evolution). Canon (repo) skill names are
|
|
3147
|
+
reserved so a generated skill never shadows them. The model may decline
|
|
3148
|
+
(reply "no skill"). Never raises — a reflection failure must not break the
|
|
3149
|
+
REPL.
|
|
3150
|
+
"""
|
|
3151
|
+
console.print_status("Reflecting on this session to draft a reusable skill...")
|
|
3152
|
+
# Evolution candidates = generated *live* skills only (pending excluded by
|
|
3153
|
+
# the one-level glob; canon excluded by scanning the generated root alone).
|
|
3154
|
+
candidates = [(meta.skill_name, meta.description) for meta in SkillLoader(store.root).scan()]
|
|
3155
|
+
reserved_names = skill_loader.canon_skill_names()
|
|
3156
|
+
reflection_messages = list(messages)
|
|
3157
|
+
reflection_messages.append({"role": "user", "content": render_reflection_prompt(candidates)})
|
|
3158
|
+
draft_handlers: dict[str, Any] = {
|
|
3159
|
+
"skill_create": partial(run_skill_create, store=store, reserved_names=reserved_names)
|
|
3160
|
+
}
|
|
3161
|
+
draft_tools = [SKILL_CREATE_TOOL_SCHEMA]
|
|
3162
|
+
# Only offer the read tool when there is something to refine, so the no-skill
|
|
3163
|
+
# case stays byte-identical to the create-only behavior.
|
|
3164
|
+
if candidates:
|
|
3165
|
+
draft_tools = [SKILL_CREATE_TOOL_SCHEMA, *LOAD_SKILL_TOOL_SCHEMAS]
|
|
3166
|
+
draft_handlers["load_skill"] = skill_loader.load
|
|
3167
|
+
try:
|
|
3168
|
+
agent_loop(
|
|
3169
|
+
provider=provider,
|
|
3170
|
+
messages=reflection_messages,
|
|
3171
|
+
tools=draft_tools,
|
|
3172
|
+
handlers=draft_handlers,
|
|
3173
|
+
permission=permission,
|
|
3174
|
+
stream=False,
|
|
3175
|
+
console=console,
|
|
3176
|
+
max_iterations=6,
|
|
3177
|
+
token_tracker=token_tracker,
|
|
3178
|
+
skill_gen=None,
|
|
3179
|
+
)
|
|
3180
|
+
except (LLMCallError, KeyboardInterrupt):
|
|
3181
|
+
console.print_status("Skill reflection skipped (interrupted or LLM error).")
|
|
3182
|
+
return
|
|
3183
|
+
except Exception as exc: # never let reflection break the REPL
|
|
3184
|
+
console.print_error(f"Skill reflection failed: {type(exc).__name__}: {exc}")
|
|
3185
|
+
return
|
|
3186
|
+
removed = store.prune_pending(max_pending)
|
|
3187
|
+
pending = store.list_pending()
|
|
3188
|
+
if pending:
|
|
3189
|
+
console.print_status(
|
|
3190
|
+
"Pending skills: "
|
|
3191
|
+
+ ", ".join(pending)
|
|
3192
|
+
+ " — /skill keep <name> to keep, /skill discard <name> to drop."
|
|
3193
|
+
)
|
|
3194
|
+
if removed:
|
|
3195
|
+
console.print_status(f"Pruned old pending drafts: {', '.join(removed)}")
|
|
3196
|
+
|
|
3197
|
+
|
|
3198
|
+
def _run_goal_evaluator(
|
|
3199
|
+
*,
|
|
3200
|
+
provider: BaseLLMProvider,
|
|
3201
|
+
messages: list[dict[str, Any]],
|
|
3202
|
+
condition: str,
|
|
3203
|
+
console: AgentConsole,
|
|
3204
|
+
token_tracker: Any,
|
|
3205
|
+
permission: Any,
|
|
3206
|
+
) -> Verdict:
|
|
3207
|
+
"""Isolated evaluator: judge whether ``condition`` is met from the transcript.
|
|
3208
|
+
|
|
3209
|
+
Mirrors :func:`_run_skill_reflection`: runs ``agent_loop`` on a COPY of the
|
|
3210
|
+
messages with ``goal_verdict`` as the only tool, so real history / turn
|
|
3211
|
+
results stay clean. Never raises for ordinary failures — an LLM error or a
|
|
3212
|
+
missing verdict yields a malformed (= not met) verdict so the loop falls
|
|
3213
|
+
through to its ``max_turns`` guard rather than crashing. ``KeyboardInterrupt``
|
|
3214
|
+
propagates so the user can abort the whole goal loop.
|
|
3215
|
+
"""
|
|
3216
|
+
sink: list[Verdict] = []
|
|
3217
|
+
eval_messages = list(messages)
|
|
3218
|
+
eval_messages.append({"role": "user", "content": build_evaluator_prompt(condition)})
|
|
3219
|
+
try:
|
|
3220
|
+
agent_loop(
|
|
3221
|
+
provider=provider,
|
|
3222
|
+
messages=eval_messages,
|
|
3223
|
+
tools=[GOAL_VERDICT_TOOL_SCHEMA],
|
|
3224
|
+
handlers={"goal_verdict": partial(run_goal_verdict, sink=sink)},
|
|
3225
|
+
permission=permission,
|
|
3226
|
+
stream=False,
|
|
3227
|
+
console=console,
|
|
3228
|
+
max_iterations=3,
|
|
3229
|
+
token_tracker=token_tracker,
|
|
3230
|
+
skill_gen=None,
|
|
3231
|
+
)
|
|
3232
|
+
except KeyboardInterrupt:
|
|
3233
|
+
raise
|
|
3234
|
+
except Exception as exc: # noqa: BLE001 - evaluator failure must not break the loop
|
|
3235
|
+
console.print_error(f"Goal evaluator failed: {type(exc).__name__}: {exc}")
|
|
3236
|
+
return Verdict(met=False, reason="evaluator error", malformed=True)
|
|
3237
|
+
if not sink:
|
|
3238
|
+
return Verdict(met=False, reason="evaluator returned no verdict", malformed=True)
|
|
3239
|
+
return sink[-1]
|
|
3240
|
+
|
|
3241
|
+
|
|
3242
|
+
def _drive_goal(
|
|
3243
|
+
command: Any,
|
|
3244
|
+
*,
|
|
3245
|
+
provider: BaseLLMProvider,
|
|
3246
|
+
evaluator_provider: BaseLLMProvider,
|
|
3247
|
+
messages: list[dict[str, Any]],
|
|
3248
|
+
loop_tools: list[dict[str, Any]],
|
|
3249
|
+
handlers: dict[str, Any],
|
|
3250
|
+
permission: PermissionGuard,
|
|
3251
|
+
compact_fn: Any,
|
|
3252
|
+
bg_manager: Any,
|
|
3253
|
+
config: Config,
|
|
3254
|
+
ui_console: AgentConsole,
|
|
3255
|
+
interaction_logger: Any,
|
|
3256
|
+
token_tracker: Any,
|
|
3257
|
+
hook_engine: Any,
|
|
3258
|
+
retry_policy: RetryPolicy,
|
|
3259
|
+
transcript_mgr: Any,
|
|
3260
|
+
) -> None:
|
|
3261
|
+
"""Run the synchronous self-driving goal loop for a parsed ``run`` command.
|
|
3262
|
+
|
|
3263
|
+
Each turn runs the real ``agent_loop`` (sharing the main loop's tools /
|
|
3264
|
+
handlers / permission), then an isolated evaluator decides whether to stop.
|
|
3265
|
+
The loop respects the current permission mode (never auto-escalates); in
|
|
3266
|
+
DEFAULT it warns that writes still prompt each turn. ``skill_gen`` is omitted
|
|
3267
|
+
so the inner turns never trigger skill reflection. Interrupts / LLM errors
|
|
3268
|
+
roll back the in-flight turn and abort the loop, leaving completed turns.
|
|
3269
|
+
"""
|
|
3270
|
+
state = GoalState(condition=command.condition, max_turns=command.max_turns)
|
|
3271
|
+
if permission.mode == PermissionMode.DEFAULT:
|
|
3272
|
+
ui_console.print_status(
|
|
3273
|
+
"Goal set in DEFAULT mode: write operations still prompt each turn. "
|
|
3274
|
+
"Use /auto first for an unattended run."
|
|
3275
|
+
)
|
|
3276
|
+
ui_console.print_status(f"Goal: {state.condition} (max {state.max_turns} turns)")
|
|
3277
|
+
|
|
3278
|
+
def run_turn(prompt: str) -> None:
|
|
3279
|
+
snapshot = len(messages)
|
|
3280
|
+
messages.append({"role": "user", "content": prompt})
|
|
3281
|
+
try:
|
|
3282
|
+
agent_loop(
|
|
3283
|
+
provider=provider,
|
|
3284
|
+
messages=messages,
|
|
3285
|
+
tools=loop_tools,
|
|
3286
|
+
handlers=handlers,
|
|
3287
|
+
permission=permission,
|
|
3288
|
+
compact_fn=compact_fn,
|
|
3289
|
+
bg_manager=bg_manager,
|
|
3290
|
+
stream=config.ui.stream,
|
|
3291
|
+
console=ui_console,
|
|
3292
|
+
interaction_logger=interaction_logger,
|
|
3293
|
+
token_tracker=token_tracker,
|
|
3294
|
+
hook_engine=hook_engine,
|
|
3295
|
+
retry_policy=retry_policy,
|
|
3296
|
+
skill_gen=None,
|
|
3297
|
+
)
|
|
3298
|
+
_save_transcript_snapshot(transcript_mgr, messages, compact_fn)
|
|
3299
|
+
except (LLMCallError, KeyboardInterrupt):
|
|
3300
|
+
del messages[snapshot:]
|
|
3301
|
+
raise
|
|
3302
|
+
|
|
3303
|
+
def evaluate() -> Verdict:
|
|
3304
|
+
return _run_goal_evaluator(
|
|
3305
|
+
provider=evaluator_provider,
|
|
3306
|
+
messages=messages,
|
|
3307
|
+
condition=state.condition,
|
|
3308
|
+
console=ui_console,
|
|
3309
|
+
token_tracker=token_tracker,
|
|
3310
|
+
permission=permission,
|
|
3311
|
+
)
|
|
3312
|
+
|
|
3313
|
+
try:
|
|
3314
|
+
outcome, verdict = run_goal_loop(
|
|
3315
|
+
state,
|
|
3316
|
+
run_turn=run_turn,
|
|
3317
|
+
evaluate=evaluate,
|
|
3318
|
+
on_progress=ui_console.print_status,
|
|
3319
|
+
)
|
|
3320
|
+
except KeyboardInterrupt:
|
|
3321
|
+
ui_console.print_status(f"Goal aborted after {state.turns_used} turn(s).")
|
|
3322
|
+
return
|
|
3323
|
+
except LLMCallError:
|
|
3324
|
+
ui_console.print_error(
|
|
3325
|
+
f"Goal aborted: LLM call failed after {state.turns_used} turn(s)."
|
|
3326
|
+
)
|
|
3327
|
+
return
|
|
3328
|
+
|
|
3329
|
+
if outcome is GoalOutcome.MET:
|
|
3330
|
+
ui_console.print_status(f"Goal met after {state.turns_used} turn(s).")
|
|
3331
|
+
else:
|
|
3332
|
+
last_reason = verdict.reason if verdict else ""
|
|
3333
|
+
msg = f"Goal not met after {state.turns_used} turn(s) (max turns reached)."
|
|
3334
|
+
if last_reason:
|
|
3335
|
+
msg += f" Last evaluator note: {last_reason}"
|
|
3336
|
+
ui_console.print_status(msg)
|
|
3337
|
+
|
|
3338
|
+
|
|
3339
|
+
def _print_skill_list(store: SkillStore, loader: SkillLoader, console: AgentConsole) -> None:
|
|
3340
|
+
live = [meta.skill_name for meta in loader.scan()]
|
|
3341
|
+
live_set = set(live)
|
|
3342
|
+
pending = store.list_pending()
|
|
3343
|
+
lines = ["Loadable skills:"]
|
|
3344
|
+
lines += [f" - {name}" for name in live] or [" (none)"]
|
|
3345
|
+
lines.append("Pending drafts:")
|
|
3346
|
+
pending_lines = []
|
|
3347
|
+
for name in pending:
|
|
3348
|
+
# A pending draft whose name matches a loadable skill is a revision that
|
|
3349
|
+
# will REPLACE the live version on /skill keep (self-evolution).
|
|
3350
|
+
revision = f" (revision of live '{name}')" if name in live_set else ""
|
|
3351
|
+
pending_lines.append(
|
|
3352
|
+
f" - {name}{revision} (/skill keep {name} | /skill discard {name})"
|
|
3353
|
+
)
|
|
3354
|
+
lines += pending_lines or [" (none)"]
|
|
3355
|
+
console.print_status("\n".join(lines))
|
|
3356
|
+
|
|
3357
|
+
|
|
3358
|
+
def _dispatch_skill_command(
|
|
3359
|
+
text: str,
|
|
3360
|
+
*,
|
|
3361
|
+
store: SkillStore,
|
|
3362
|
+
loader: SkillLoader,
|
|
3363
|
+
console: AgentConsole,
|
|
3364
|
+
) -> None:
|
|
3365
|
+
"""Handle ``/skill`` (``list`` | ``keep <name>`` | ``discard <name>``).
|
|
3366
|
+
|
|
3367
|
+
Never raises — fails safe with an error line so a bad argument can't crash
|
|
3368
|
+
the REPL (same stance as the other ``_dispatch_*`` commands).
|
|
3369
|
+
"""
|
|
3370
|
+
parts = text.split()
|
|
3371
|
+
sub = parts[1].lower() if len(parts) > 1 else "list"
|
|
3372
|
+
arg = parts[2] if len(parts) > 2 else ""
|
|
3373
|
+
try:
|
|
3374
|
+
if sub == "list":
|
|
3375
|
+
_print_skill_list(store, loader, console)
|
|
3376
|
+
return
|
|
3377
|
+
if sub == "keep":
|
|
3378
|
+
if not arg:
|
|
3379
|
+
console.print_error("Usage: /skill keep <name>")
|
|
3380
|
+
return
|
|
3381
|
+
console.print_status(store.promote(arg))
|
|
3382
|
+
# Refresh the cache so the promoted skill is loadable this session.
|
|
3383
|
+
loader.scan()
|
|
3384
|
+
return
|
|
3385
|
+
if sub == "discard":
|
|
3386
|
+
if not arg:
|
|
3387
|
+
console.print_error("Usage: /skill discard <name>")
|
|
3388
|
+
return
|
|
3389
|
+
console.print_status(store.discard(arg))
|
|
3390
|
+
return
|
|
3391
|
+
console.print_error(
|
|
3392
|
+
f"Unknown /skill subcommand {sub!r}. "
|
|
3393
|
+
"Use: /skill [list | keep <name> | discard <name>]."
|
|
3394
|
+
)
|
|
3395
|
+
except SkillStoreError as exc:
|
|
3396
|
+
console.print_error(f"Error: {exc}")
|
|
3397
|
+
except Exception as exc: # never raise out of a slash command
|
|
3398
|
+
console.print_error(f"/skill failed: {type(exc).__name__}: {exc}")
|
|
3399
|
+
|
|
3400
|
+
|
|
3401
|
+
def _dispatch_export_command(
|
|
3402
|
+
text: str,
|
|
3403
|
+
*,
|
|
3404
|
+
messages: list[dict[str, Any]],
|
|
3405
|
+
session_id: str,
|
|
3406
|
+
workspace_path: Path,
|
|
3407
|
+
ui_console: AgentConsole,
|
|
3408
|
+
) -> None:
|
|
3409
|
+
"""Handle the ``/export`` REPL command.
|
|
3410
|
+
|
|
3411
|
+
Forms: ``/export`` (markdown default), ``/export <format>`` and
|
|
3412
|
+
``/export <format> <path>`` where format ∈ {markdown, md, json}; the first
|
|
3413
|
+
token, when not a known format, is treated as an explicit path with the
|
|
3414
|
+
default markdown format. Markdown skips system messages and omits thinking;
|
|
3415
|
+
JSON is a faithful self-contained wrapper. Defaults to
|
|
3416
|
+
``.transcripts/exports/<session>_<ts>.{md,json}``. Runs without permission
|
|
3417
|
+
confirmation (user-initiated, infrastructure tier). Never raises.
|
|
3418
|
+
"""
|
|
3419
|
+
try:
|
|
3420
|
+
rest = text[len("/export") :].strip()
|
|
3421
|
+
parts = rest.split(maxsplit=1)
|
|
3422
|
+
if parts and parts[0] in ("markdown", "md", "json"):
|
|
3423
|
+
fmt = "markdown" if parts[0] in ("markdown", "md") else "json"
|
|
3424
|
+
user_path = parts[1].strip() if len(parts) > 1 else ""
|
|
3425
|
+
else:
|
|
3426
|
+
fmt = "markdown"
|
|
3427
|
+
user_path = rest
|
|
3428
|
+
|
|
3429
|
+
ext = "json" if fmt == "json" else "md"
|
|
3430
|
+
if user_path:
|
|
3431
|
+
path = Path(user_path).expanduser()
|
|
3432
|
+
if not path.is_absolute():
|
|
3433
|
+
path = workspace_path / path
|
|
3434
|
+
else:
|
|
3435
|
+
timestamp = datetime.now().strftime(_SESSION_ID_TIMESTAMP_FORMAT)
|
|
3436
|
+
path = workspace_path / ".transcripts" / "exports" / f"{session_id}_{timestamp}.{ext}"
|
|
3437
|
+
|
|
3438
|
+
if fmt == "json":
|
|
3439
|
+
content = to_export_json(
|
|
3440
|
+
messages,
|
|
3441
|
+
session_id=session_id,
|
|
3442
|
+
exported_at=datetime.now().isoformat(),
|
|
3443
|
+
)
|
|
3444
|
+
else:
|
|
3445
|
+
content = render_markdown(messages, title=f"Conversation {session_id}")
|
|
3446
|
+
|
|
3447
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
3448
|
+
atomic_write_text(path, content)
|
|
3449
|
+
ui_console.print_status(f"Exported to {path}")
|
|
3450
|
+
except Exception as exc: # noqa: BLE001 - never crash the REPL on export
|
|
3451
|
+
ui_console.print_error(f"Export failed: {exc}")
|
|
3452
|
+
|
|
3453
|
+
|
|
3454
|
+
def _run_stdio_session(
|
|
3455
|
+
config: Config,
|
|
3456
|
+
provider: BaseLLMProvider,
|
|
3457
|
+
*,
|
|
3458
|
+
workspace: Path | None = None,
|
|
3459
|
+
agent_console: AgentConsole | None = None,
|
|
3460
|
+
) -> int:
|
|
3461
|
+
from bareagent.ui.theme import init_theme
|
|
3462
|
+
|
|
3463
|
+
init_theme(config.ui.theme)
|
|
3464
|
+
ui_console = agent_console or AgentConsole()
|
|
3465
|
+
ui_console.set_theme()
|
|
3466
|
+
workspace_path = (workspace or Path.cwd()).resolve()
|
|
3467
|
+
transcript_mgr = TranscriptManager(workspace_path / ".transcripts")
|
|
3468
|
+
session_id = _generate_session_id(transcript_mgr)
|
|
3469
|
+
interaction_logger = _build_interaction_logger(
|
|
3470
|
+
config,
|
|
3471
|
+
workspace_path,
|
|
3472
|
+
session_id,
|
|
3473
|
+
)
|
|
3474
|
+
_configure_tracing(
|
|
3475
|
+
config,
|
|
3476
|
+
session_id=session_id,
|
|
3477
|
+
interaction_logger=interaction_logger,
|
|
3478
|
+
)
|
|
3479
|
+
viewer_server = None
|
|
3480
|
+
token_tracker = TokenTracker()
|
|
3481
|
+
todo_manager = TodoManager()
|
|
3482
|
+
task_manager = _load_task_manager(workspace_path, ui_console)
|
|
3483
|
+
bg_manager = BackgroundManager()
|
|
3484
|
+
scheduler = Scheduler(
|
|
3485
|
+
runner=partial(run_bash, cwd=workspace_path, raise_on_error=True),
|
|
3486
|
+
notifier=bg_manager,
|
|
3487
|
+
)
|
|
3488
|
+
teammate_manager = _load_teammate_manager(workspace_path, ui_console)
|
|
3489
|
+
# Experiential skill generation: generated skills live under a project-
|
|
3490
|
+
# isolated user-global root (separate from the repo's checked-in canon).
|
|
3491
|
+
# SkillLoader scans both (canon wins on name conflicts); SkillStore owns the
|
|
3492
|
+
# pending drafts + promotion; SkillGenerator owns the trigger decision.
|
|
3493
|
+
generated_skills_root = resolve_generated_skills_root(workspace_path, config.skills.dir)
|
|
3494
|
+
skill_store = SkillStore(generated_skills_root)
|
|
3495
|
+
skill_loader = SkillLoader(resolve_skills_dir(), generated_root=generated_skills_root)
|
|
3496
|
+
skillgen_config = _build_skillgen_config(config.skills)
|
|
3497
|
+
skill_generator = SkillGenerator(skillgen_config) if skillgen_config.enabled else None
|
|
3498
|
+
memory_manager = _build_memory_manager(config, workspace_path, ui_console)
|
|
3499
|
+
message_bus, main_mailbox_cursor = _switch_session_mailbox(
|
|
3500
|
+
workspace_path,
|
|
3501
|
+
session_id,
|
|
3502
|
+
)
|
|
3503
|
+
spawned_agents: dict[str, AutonomousAgent] = {}
|
|
3504
|
+
# Resumable foreground subagents (task 06-06): session-scoped, in-memory,
|
|
3505
|
+
# one instance for the REPL lifetime. Cleared on /new / /resume / /import /
|
|
3506
|
+
# /clear (mirroring spawned_agents) and preserved across /compact.
|
|
3507
|
+
subagent_registry = SubagentRegistry(config.subagent.max_resumable)
|
|
3508
|
+
# Workflow runs (task 06-08): session-scoped, in-memory, thread-safe store
|
|
3509
|
+
# backing the /workflows panel + resume. Same lifecycle as subagent_registry
|
|
3510
|
+
# / spawned_agents -- cleared on /new / /resume / /import / /clear, kept
|
|
3511
|
+
# across /compact.
|
|
3512
|
+
workflow_registry = WorkflowRegistry(config.workflow.max_runs)
|
|
3513
|
+
# Late / unsolicited teammate replies surfaced by the mailbox drain are
|
|
3514
|
+
# buffered here and prepended onto the next user turn (keeps role
|
|
3515
|
+
# alternation intact). Blocking team_send replies bypass this -- they return
|
|
3516
|
+
# straight to the LLM as the tool result.
|
|
3517
|
+
pending_team_messages: list[str] = []
|
|
3518
|
+
# Finished background workflows' full summaries, buffered the same way and
|
|
3519
|
+
# prepended onto the next user turn (see _drain_workflow_results). Same
|
|
3520
|
+
# lifecycle as pending_team_messages.
|
|
3521
|
+
pending_workflow_messages: list[str] = []
|
|
3522
|
+
messages = _initial_messages(
|
|
3523
|
+
workspace_path,
|
|
3524
|
+
skill_summary=skill_loader.get_skill_list_prompt(),
|
|
3525
|
+
memory_context=_memory_context(memory_manager),
|
|
3526
|
+
)
|
|
3527
|
+
mcp_manager = MCPManager(config.mcp, console=ui_console, notifier=bg_manager)
|
|
3528
|
+
mcp_manager.start_all()
|
|
3529
|
+
_install_mcp_cleanup(mcp_manager)
|
|
3530
|
+
lsp_manager = LanguageServerManager(
|
|
3531
|
+
config.lsp,
|
|
3532
|
+
console=ui_console,
|
|
3533
|
+
repository_root=str(workspace_path),
|
|
3534
|
+
notifier=bg_manager,
|
|
3535
|
+
)
|
|
3536
|
+
lsp_manager.start_all()
|
|
3537
|
+
_install_lsp_cleanup(lsp_manager)
|
|
3538
|
+
tools = get_tools(mcp_manager, lsp_manager)
|
|
3539
|
+
permission = _build_permission_guard(config)
|
|
3540
|
+
_install_stdio_permission_prompt(permission, ui_console)
|
|
3541
|
+
read_fn = _build_stdio_read_fn(workspace_path, permission)
|
|
3542
|
+
base_compact_fn = Compactor(
|
|
3543
|
+
provider=provider,
|
|
3544
|
+
transcript_mgr=transcript_mgr,
|
|
3545
|
+
session_id=session_id,
|
|
3546
|
+
)
|
|
3547
|
+
compact_fn = _build_loop_compact(
|
|
3548
|
+
base_compact_fn,
|
|
3549
|
+
todo_manager,
|
|
3550
|
+
memory_manager=memory_manager,
|
|
3551
|
+
recall_k=config.memory.recall_k,
|
|
3552
|
+
permission=permission,
|
|
3553
|
+
)
|
|
3554
|
+
# Hooks only fire in the main loop; sub-agents never receive the engine.
|
|
3555
|
+
hook_engine = HookEngine(config.hooks, console=ui_console)
|
|
3556
|
+
# Sub-agents *do* inherit the retry policy (D6) so background agents weather
|
|
3557
|
+
# transient failures too; threaded through _build_handlers -> get_handlers.
|
|
3558
|
+
retry_policy = _build_retry_policy(config.retry)
|
|
3559
|
+
# Provider for the /goal completion evaluator: a cheaper model if configured,
|
|
3560
|
+
# else the session provider (built once; reused across goal turns).
|
|
3561
|
+
goal_evaluator_provider = _build_goal_provider(config, provider)
|
|
3562
|
+
handlers = _build_handlers(
|
|
3563
|
+
workspace_path=workspace_path,
|
|
3564
|
+
todo_manager=todo_manager,
|
|
3565
|
+
task_manager=task_manager,
|
|
3566
|
+
skill_loader=skill_loader,
|
|
3567
|
+
provider=provider,
|
|
3568
|
+
tools=tools,
|
|
3569
|
+
permission=permission,
|
|
3570
|
+
bg_manager=bg_manager,
|
|
3571
|
+
messages=messages,
|
|
3572
|
+
config=config,
|
|
3573
|
+
runtime_id=session_id,
|
|
3574
|
+
teammate_manager=teammate_manager,
|
|
3575
|
+
message_bus=message_bus,
|
|
3576
|
+
spawned_agents=spawned_agents,
|
|
3577
|
+
agent_name=MAIN_AGENT_NAME,
|
|
3578
|
+
mcp_manager=mcp_manager,
|
|
3579
|
+
lsp_manager=lsp_manager,
|
|
3580
|
+
memory_manager=memory_manager,
|
|
3581
|
+
subagent_registry=subagent_registry,
|
|
3582
|
+
)
|
|
3583
|
+
|
|
3584
|
+
# Plan-mode workflow: exit_plan_mode is a main-loop-only tool. ``tools`` stays
|
|
3585
|
+
# the canonical base list fed to every _build_handlers call (sub-agent and
|
|
3586
|
+
# teammate closures inherit it, so they never see exit_plan_mode); the
|
|
3587
|
+
# augmented ``loop_tools`` is fed ONLY to the top-level agent_loop calls. Its
|
|
3588
|
+
# handler is installed on the live dict (and re-installed after every session
|
|
3589
|
+
# switch below, since those rebuild ``handlers`` from scratch). Defense in
|
|
3590
|
+
# depth: filter_tools also strips MAIN_LOOP_ONLY_TOOLS for every agent type
|
|
3591
|
+
# and filter_handlers drops the orphaned handler. ``plan_approval`` closes
|
|
3592
|
+
# over the permission guard + console, so the same callback is reused on every
|
|
3593
|
+
# rebuild.
|
|
3594
|
+
plan_approval = _make_plan_approval(permission, ui_console)
|
|
3595
|
+
loop_tools = [*tools, EXIT_PLAN_MODE_TOOL_SCHEMA]
|
|
3596
|
+
# Workflow orchestration (task 06-06): ``workflow`` is a main-loop-only tool
|
|
3597
|
+
# like ``exit_plan_mode`` -- its schema joins ``loop_tools`` (never the base
|
|
3598
|
+
# ``tools`` fed to sub-agent closures) and its handler is (re)installed after
|
|
3599
|
+
# every ``_build_handlers`` via ``install_workflow_handler``. When disabled the
|
|
3600
|
+
# schema is withheld and the install is a no-op, so the feature short-circuits.
|
|
3601
|
+
if config.workflow.enabled:
|
|
3602
|
+
loop_tools.append(WORKFLOW_TOOL_SCHEMA)
|
|
3603
|
+
# subagent_send (task 06-06): main-loop-only continuation tool. Always on
|
|
3604
|
+
# (no config gate); schema joins loop_tools only, handler re-installed after
|
|
3605
|
+
# every _build_handlers below. The registry instance is stable for the REPL
|
|
3606
|
+
# lifetime, so the bound install closure stays valid across rebuilds.
|
|
3607
|
+
loop_tools.append(SUBAGENT_SEND_TOOL_SCHEMA)
|
|
3608
|
+
install_subagent_send_handler = partial(
|
|
3609
|
+
_install_subagent_send_handler,
|
|
3610
|
+
registry=subagent_registry,
|
|
3611
|
+
)
|
|
3612
|
+
install_workflow_handler = partial(
|
|
3613
|
+
_install_workflow_handler,
|
|
3614
|
+
enabled=config.workflow.enabled,
|
|
3615
|
+
provider=provider,
|
|
3616
|
+
base_tools=tools,
|
|
3617
|
+
permission=permission,
|
|
3618
|
+
bg_manager=bg_manager,
|
|
3619
|
+
console=ui_console,
|
|
3620
|
+
retry_policy=retry_policy,
|
|
3621
|
+
max_depth=config.subagent.max_depth,
|
|
3622
|
+
default_agent_type=config.subagent.default_type,
|
|
3623
|
+
max_concurrency=config.workflow.max_concurrency,
|
|
3624
|
+
max_nodes=config.workflow.max_nodes,
|
|
3625
|
+
registry=workflow_registry,
|
|
3626
|
+
default_token_budget=config.workflow.default_token_budget,
|
|
3627
|
+
)
|
|
3628
|
+
_install_plan_handler(handlers, plan_approval)
|
|
3629
|
+
install_workflow_handler(handlers)
|
|
3630
|
+
install_subagent_send_handler(handlers)
|
|
3631
|
+
|
|
3632
|
+
ui_console.console.print(
|
|
3633
|
+
f"BareAgent REPL ({config.provider.name}/{config.provider.model})",
|
|
3634
|
+
style="bold cyan",
|
|
3635
|
+
)
|
|
3636
|
+
ui_console.print_status(
|
|
3637
|
+
f"Permission mode: {permission.mode.value}. Type /help to see available commands."
|
|
3638
|
+
)
|
|
3639
|
+
|
|
3640
|
+
# Passive config-change detection (ROADMAP 4.3): record the config files'
|
|
3641
|
+
# mtimes once at startup so we only nudge the user about *new* edits.
|
|
3642
|
+
last_config_mtimes = _config_mtimes(config)
|
|
3643
|
+
|
|
3644
|
+
try:
|
|
3645
|
+
while True:
|
|
3646
|
+
main_mailbox_cursor = _drain_team_mailbox(
|
|
3647
|
+
ui_console,
|
|
3648
|
+
message_bus=message_bus,
|
|
3649
|
+
since=main_mailbox_cursor,
|
|
3650
|
+
sink=pending_team_messages,
|
|
3651
|
+
)
|
|
3652
|
+
current_config_mtimes = _config_mtimes(config)
|
|
3653
|
+
if current_config_mtimes != last_config_mtimes:
|
|
3654
|
+
last_config_mtimes = current_config_mtimes
|
|
3655
|
+
ui_console.print_status("config changed on disk — type /reload to apply")
|
|
3656
|
+
try:
|
|
3657
|
+
user_input = read_fn()
|
|
3658
|
+
except (KeyboardInterrupt, EOFError):
|
|
3659
|
+
_broadcast_team_shutdown(message_bus)
|
|
3660
|
+
ui_console.print_status("\nExiting BareAgent.")
|
|
3661
|
+
return 0
|
|
3662
|
+
|
|
3663
|
+
text = user_input.strip()
|
|
3664
|
+
if not text:
|
|
3665
|
+
continue
|
|
3666
|
+
if text == "/exit":
|
|
3667
|
+
_broadcast_team_shutdown(message_bus)
|
|
3668
|
+
ui_console.print_status("Exiting BareAgent.")
|
|
3669
|
+
return 0
|
|
3670
|
+
if text == "/help":
|
|
3671
|
+
ui_console.print_status(_HELP_TEXT)
|
|
3672
|
+
continue
|
|
3673
|
+
if text in ("/clear", "/new"):
|
|
3674
|
+
if text == "/clear":
|
|
3675
|
+
_clear_stdio_screen(ui_console)
|
|
3676
|
+
messages[:] = _initial_messages(
|
|
3677
|
+
workspace_path,
|
|
3678
|
+
skill_summary=skill_loader.get_skill_list_prompt(),
|
|
3679
|
+
memory_context=_memory_context(memory_manager),
|
|
3680
|
+
)
|
|
3681
|
+
todo_manager.reset()
|
|
3682
|
+
token_tracker.reset()
|
|
3683
|
+
if skill_generator is not None:
|
|
3684
|
+
skill_generator.reset()
|
|
3685
|
+
new_session_id = _generate_session_id(
|
|
3686
|
+
transcript_mgr,
|
|
3687
|
+
reserved_ids={_get_compact_session_id(compact_fn)},
|
|
3688
|
+
)
|
|
3689
|
+
_set_compact_session_id(compact_fn, new_session_id)
|
|
3690
|
+
_set_interaction_logger_session(interaction_logger, new_session_id)
|
|
3691
|
+
message_bus, main_mailbox_cursor = _switch_session_mailbox(
|
|
3692
|
+
workspace_path,
|
|
3693
|
+
new_session_id,
|
|
3694
|
+
current_bus=message_bus,
|
|
3695
|
+
)
|
|
3696
|
+
spawned_agents = {}
|
|
3697
|
+
pending_team_messages.clear()
|
|
3698
|
+
pending_workflow_messages.clear()
|
|
3699
|
+
subagent_registry.clear()
|
|
3700
|
+
workflow_registry.clear()
|
|
3701
|
+
handlers = _build_handlers(
|
|
3702
|
+
workspace_path=workspace_path,
|
|
3703
|
+
todo_manager=todo_manager,
|
|
3704
|
+
task_manager=task_manager,
|
|
3705
|
+
skill_loader=skill_loader,
|
|
3706
|
+
provider=provider,
|
|
3707
|
+
tools=tools,
|
|
3708
|
+
permission=permission,
|
|
3709
|
+
bg_manager=bg_manager,
|
|
3710
|
+
messages=messages,
|
|
3711
|
+
config=config,
|
|
3712
|
+
runtime_id=new_session_id,
|
|
3713
|
+
teammate_manager=teammate_manager,
|
|
3714
|
+
message_bus=message_bus,
|
|
3715
|
+
spawned_agents=spawned_agents,
|
|
3716
|
+
agent_name=MAIN_AGENT_NAME,
|
|
3717
|
+
mcp_manager=mcp_manager,
|
|
3718
|
+
lsp_manager=lsp_manager,
|
|
3719
|
+
memory_manager=memory_manager,
|
|
3720
|
+
subagent_registry=subagent_registry,
|
|
3721
|
+
)
|
|
3722
|
+
_install_plan_handler(handlers, plan_approval)
|
|
3723
|
+
install_workflow_handler(handlers)
|
|
3724
|
+
install_subagent_send_handler(handlers)
|
|
3725
|
+
ui_console.print_status("New conversation started.")
|
|
3726
|
+
continue
|
|
3727
|
+
if text == "/compact":
|
|
3728
|
+
compact_fn(messages, force=True)
|
|
3729
|
+
_save_transcript_snapshot(transcript_mgr, messages, compact_fn)
|
|
3730
|
+
ui_console.print_status("Context compaction finished.")
|
|
3731
|
+
handlers = _build_handlers(
|
|
3732
|
+
workspace_path=workspace_path,
|
|
3733
|
+
todo_manager=todo_manager,
|
|
3734
|
+
task_manager=task_manager,
|
|
3735
|
+
skill_loader=skill_loader,
|
|
3736
|
+
provider=provider,
|
|
3737
|
+
tools=tools,
|
|
3738
|
+
permission=permission,
|
|
3739
|
+
bg_manager=bg_manager,
|
|
3740
|
+
messages=messages,
|
|
3741
|
+
config=config,
|
|
3742
|
+
runtime_id=_get_compact_session_id(compact_fn),
|
|
3743
|
+
teammate_manager=teammate_manager,
|
|
3744
|
+
message_bus=message_bus,
|
|
3745
|
+
spawned_agents=spawned_agents,
|
|
3746
|
+
agent_name=MAIN_AGENT_NAME,
|
|
3747
|
+
mcp_manager=mcp_manager,
|
|
3748
|
+
lsp_manager=lsp_manager,
|
|
3749
|
+
memory_manager=memory_manager,
|
|
3750
|
+
subagent_registry=subagent_registry,
|
|
3751
|
+
)
|
|
3752
|
+
_install_plan_handler(handlers, plan_approval)
|
|
3753
|
+
install_workflow_handler(handlers)
|
|
3754
|
+
install_subagent_send_handler(handlers)
|
|
3755
|
+
continue
|
|
3756
|
+
if text == "/sessions":
|
|
3757
|
+
sessions = transcript_mgr.list_sessions()
|
|
3758
|
+
if not sessions:
|
|
3759
|
+
ui_console.print_status("No saved sessions.")
|
|
3760
|
+
else:
|
|
3761
|
+
for saved_session in sessions:
|
|
3762
|
+
ui_console.console.print(saved_session)
|
|
3763
|
+
continue
|
|
3764
|
+
if text == "/resume" or text.startswith("/resume "):
|
|
3765
|
+
_, _, raw_session_id = text.partition(" ")
|
|
3766
|
+
requested_session = raw_session_id.strip() or None
|
|
3767
|
+
try:
|
|
3768
|
+
restored_messages = transcript_mgr.resume(requested_session)
|
|
3769
|
+
except FileNotFoundError as exc:
|
|
3770
|
+
ui_console.print_error(str(exc))
|
|
3771
|
+
continue
|
|
3772
|
+
messages[:] = restored_messages
|
|
3773
|
+
token_tracker.reset()
|
|
3774
|
+
resumed_session = requested_session or transcript_mgr.get_latest_session()
|
|
3775
|
+
if resumed_session is not None:
|
|
3776
|
+
_set_compact_session_id(compact_fn, resumed_session)
|
|
3777
|
+
_set_interaction_logger_session(
|
|
3778
|
+
interaction_logger,
|
|
3779
|
+
resumed_session,
|
|
3780
|
+
)
|
|
3781
|
+
message_bus, main_mailbox_cursor = _switch_session_mailbox(
|
|
3782
|
+
workspace_path,
|
|
3783
|
+
resumed_session,
|
|
3784
|
+
current_bus=message_bus,
|
|
3785
|
+
)
|
|
3786
|
+
spawned_agents = {}
|
|
3787
|
+
pending_team_messages.clear()
|
|
3788
|
+
pending_workflow_messages.clear()
|
|
3789
|
+
subagent_registry.clear()
|
|
3790
|
+
workflow_registry.clear()
|
|
3791
|
+
handlers = _build_handlers(
|
|
3792
|
+
workspace_path=workspace_path,
|
|
3793
|
+
todo_manager=todo_manager,
|
|
3794
|
+
task_manager=task_manager,
|
|
3795
|
+
skill_loader=skill_loader,
|
|
3796
|
+
provider=provider,
|
|
3797
|
+
tools=tools,
|
|
3798
|
+
permission=permission,
|
|
3799
|
+
bg_manager=bg_manager,
|
|
3800
|
+
messages=messages,
|
|
3801
|
+
config=config,
|
|
3802
|
+
runtime_id=_get_compact_session_id(compact_fn),
|
|
3803
|
+
teammate_manager=teammate_manager,
|
|
3804
|
+
message_bus=message_bus,
|
|
3805
|
+
spawned_agents=spawned_agents,
|
|
3806
|
+
agent_name=MAIN_AGENT_NAME,
|
|
3807
|
+
mcp_manager=mcp_manager,
|
|
3808
|
+
lsp_manager=lsp_manager,
|
|
3809
|
+
memory_manager=memory_manager,
|
|
3810
|
+
subagent_registry=subagent_registry,
|
|
3811
|
+
)
|
|
3812
|
+
_install_plan_handler(handlers, plan_approval)
|
|
3813
|
+
install_workflow_handler(handlers)
|
|
3814
|
+
install_subagent_send_handler(handlers)
|
|
3815
|
+
_replay_stdio_transcript(messages, ui_console)
|
|
3816
|
+
ui_console.print_status(f"Resumed session: {resumed_session}")
|
|
3817
|
+
continue
|
|
3818
|
+
if text == "/export" or text.startswith("/export "):
|
|
3819
|
+
_dispatch_export_command(
|
|
3820
|
+
text,
|
|
3821
|
+
messages=messages,
|
|
3822
|
+
session_id=_get_compact_session_id(compact_fn),
|
|
3823
|
+
workspace_path=workspace_path,
|
|
3824
|
+
ui_console=ui_console,
|
|
3825
|
+
)
|
|
3826
|
+
continue
|
|
3827
|
+
if text == "/import" or text.startswith("/import "):
|
|
3828
|
+
_, _, raw_path = text.partition(" ")
|
|
3829
|
+
import_path = raw_path.strip()
|
|
3830
|
+
if not import_path:
|
|
3831
|
+
ui_console.print_error("Usage: /import <path-to-.json-or-.jsonl>")
|
|
3832
|
+
continue
|
|
3833
|
+
p = Path(import_path).expanduser()
|
|
3834
|
+
try:
|
|
3835
|
+
raw_text = p.read_text(encoding="utf-8")
|
|
3836
|
+
except OSError as exc:
|
|
3837
|
+
ui_console.print_error(f"Cannot read {p}: {exc}")
|
|
3838
|
+
continue
|
|
3839
|
+
try:
|
|
3840
|
+
imported_messages = parse_import(raw_text)
|
|
3841
|
+
except ValueError as exc:
|
|
3842
|
+
ui_console.print_error(f"Invalid conversation file: {exc}")
|
|
3843
|
+
continue
|
|
3844
|
+
# Validation passed: only now mutate state (fail-safe — any
|
|
3845
|
+
# failure above already continued with zero changes).
|
|
3846
|
+
messages[:] = imported_messages
|
|
3847
|
+
token_tracker.reset()
|
|
3848
|
+
new_sid = _generate_session_id(
|
|
3849
|
+
transcript_mgr,
|
|
3850
|
+
reserved_ids={_get_compact_session_id(compact_fn)},
|
|
3851
|
+
)
|
|
3852
|
+
_set_compact_session_id(compact_fn, new_sid)
|
|
3853
|
+
_set_interaction_logger_session(interaction_logger, new_sid)
|
|
3854
|
+
message_bus, main_mailbox_cursor = _switch_session_mailbox(
|
|
3855
|
+
workspace_path,
|
|
3856
|
+
new_sid,
|
|
3857
|
+
current_bus=message_bus,
|
|
3858
|
+
)
|
|
3859
|
+
spawned_agents = {}
|
|
3860
|
+
pending_team_messages.clear()
|
|
3861
|
+
pending_workflow_messages.clear()
|
|
3862
|
+
subagent_registry.clear()
|
|
3863
|
+
workflow_registry.clear()
|
|
3864
|
+
handlers = _build_handlers(
|
|
3865
|
+
workspace_path=workspace_path,
|
|
3866
|
+
todo_manager=todo_manager,
|
|
3867
|
+
task_manager=task_manager,
|
|
3868
|
+
skill_loader=skill_loader,
|
|
3869
|
+
provider=provider,
|
|
3870
|
+
tools=tools,
|
|
3871
|
+
permission=permission,
|
|
3872
|
+
bg_manager=bg_manager,
|
|
3873
|
+
messages=messages,
|
|
3874
|
+
config=config,
|
|
3875
|
+
runtime_id=new_sid,
|
|
3876
|
+
teammate_manager=teammate_manager,
|
|
3877
|
+
message_bus=message_bus,
|
|
3878
|
+
spawned_agents=spawned_agents,
|
|
3879
|
+
agent_name=MAIN_AGENT_NAME,
|
|
3880
|
+
mcp_manager=mcp_manager,
|
|
3881
|
+
lsp_manager=lsp_manager,
|
|
3882
|
+
memory_manager=memory_manager,
|
|
3883
|
+
subagent_registry=subagent_registry,
|
|
3884
|
+
)
|
|
3885
|
+
_install_plan_handler(handlers, plan_approval)
|
|
3886
|
+
install_workflow_handler(handlers)
|
|
3887
|
+
install_subagent_send_handler(handlers)
|
|
3888
|
+
_replay_stdio_transcript(messages, ui_console)
|
|
3889
|
+
_save_transcript_snapshot(transcript_mgr, messages, compact_fn)
|
|
3890
|
+
ui_console.print_status(
|
|
3891
|
+
f"Imported {len(messages)} messages into new session: {new_sid}"
|
|
3892
|
+
)
|
|
3893
|
+
continue
|
|
3894
|
+
if text == "/cost":
|
|
3895
|
+
ui_console.print_status(token_tracker.summary(config.cost.prices))
|
|
3896
|
+
continue
|
|
3897
|
+
if text == "/goal" or text.startswith("/goal "):
|
|
3898
|
+
goal_cmd = parse_goal_command(
|
|
3899
|
+
text[len("/goal") :], default_max_turns=config.goal.max_turns
|
|
3900
|
+
)
|
|
3901
|
+
if goal_cmd.action == "run":
|
|
3902
|
+
_drive_goal(
|
|
3903
|
+
goal_cmd,
|
|
3904
|
+
provider=provider,
|
|
3905
|
+
evaluator_provider=goal_evaluator_provider,
|
|
3906
|
+
messages=messages,
|
|
3907
|
+
loop_tools=loop_tools,
|
|
3908
|
+
handlers=handlers,
|
|
3909
|
+
permission=permission,
|
|
3910
|
+
compact_fn=compact_fn,
|
|
3911
|
+
bg_manager=bg_manager,
|
|
3912
|
+
config=config,
|
|
3913
|
+
ui_console=ui_console,
|
|
3914
|
+
interaction_logger=interaction_logger,
|
|
3915
|
+
token_tracker=token_tracker,
|
|
3916
|
+
hook_engine=hook_engine,
|
|
3917
|
+
retry_policy=retry_policy,
|
|
3918
|
+
transcript_mgr=transcript_mgr,
|
|
3919
|
+
)
|
|
3920
|
+
elif goal_cmd.action == "error":
|
|
3921
|
+
ui_console.print_error(goal_cmd.message)
|
|
3922
|
+
else: # usage
|
|
3923
|
+
ui_console.print_status(goal_cmd.message)
|
|
3924
|
+
continue
|
|
3925
|
+
if text == "/loop" or text.startswith("/loop "):
|
|
3926
|
+
_dispatch_loop_command(text, scheduler=scheduler, ui_console=ui_console)
|
|
3927
|
+
continue
|
|
3928
|
+
if text == "/workflows" or text.startswith("/workflows "):
|
|
3929
|
+
_dispatch_workflows_command(
|
|
3930
|
+
text, registry=workflow_registry, ui_console=ui_console
|
|
3931
|
+
)
|
|
3932
|
+
continue
|
|
3933
|
+
if text == "/log" or text.startswith("/log "):
|
|
3934
|
+
viewer_server = _handle_log_command(
|
|
3935
|
+
text,
|
|
3936
|
+
config=config,
|
|
3937
|
+
interaction_logger=interaction_logger,
|
|
3938
|
+
viewer_server=viewer_server,
|
|
3939
|
+
print_status=ui_console.print_status,
|
|
3940
|
+
)
|
|
3941
|
+
continue
|
|
3942
|
+
if text in _PERMISSION_SLASH:
|
|
3943
|
+
old = permission.mode
|
|
3944
|
+
permission.mode = _PERMISSION_SLASH[text]
|
|
3945
|
+
ui_console.print_status(f"Permission mode: {old.value} → {permission.mode.value}")
|
|
3946
|
+
continue
|
|
3947
|
+
if text == "/mode":
|
|
3948
|
+
_handle_mode_selection_stdio(permission, ui_console)
|
|
3949
|
+
continue
|
|
3950
|
+
if text == "/theme" or text.startswith("/theme "):
|
|
3951
|
+
from bareagent.ui.theme import (
|
|
3952
|
+
format_theme_list,
|
|
3953
|
+
format_unknown_theme,
|
|
3954
|
+
get_theme,
|
|
3955
|
+
)
|
|
3956
|
+
|
|
3957
|
+
_, _, theme_arg = text.partition(" ")
|
|
3958
|
+
theme_name = theme_arg.strip()
|
|
3959
|
+
tm = get_theme()
|
|
3960
|
+
if not theme_name:
|
|
3961
|
+
ui_console.print_status(format_theme_list(tm))
|
|
3962
|
+
continue
|
|
3963
|
+
if tm.switch(theme_name):
|
|
3964
|
+
ui_console.set_theme(tm)
|
|
3965
|
+
ui_console.print_status(f"Theme switched to: {theme_name}")
|
|
3966
|
+
continue
|
|
3967
|
+
ui_console.print_error(format_unknown_theme(theme_name))
|
|
3968
|
+
continue
|
|
3969
|
+
if text == "/team" or text.startswith("/team "):
|
|
3970
|
+
_handle_team_command(
|
|
3971
|
+
text,
|
|
3972
|
+
ui_console,
|
|
3973
|
+
teammate_manager=teammate_manager,
|
|
3974
|
+
team_handlers=handlers,
|
|
3975
|
+
)
|
|
3976
|
+
continue
|
|
3977
|
+
if text == "/mcp" or (text.startswith("/mcp ") and not text.startswith("/mcp:")):
|
|
3978
|
+
_dispatch_mcp_command(
|
|
3979
|
+
text,
|
|
3980
|
+
mcp_manager=mcp_manager,
|
|
3981
|
+
ui_console=ui_console,
|
|
3982
|
+
)
|
|
3983
|
+
continue
|
|
3984
|
+
if text == "/lsp" or text.startswith("/lsp "):
|
|
3985
|
+
_dispatch_lsp_command(
|
|
3986
|
+
text,
|
|
3987
|
+
lsp_manager=lsp_manager,
|
|
3988
|
+
ui_console=ui_console,
|
|
3989
|
+
)
|
|
3990
|
+
continue
|
|
3991
|
+
if text == "/skill" or text.startswith("/skill "):
|
|
3992
|
+
_dispatch_skill_command(
|
|
3993
|
+
text,
|
|
3994
|
+
store=skill_store,
|
|
3995
|
+
loader=skill_loader,
|
|
3996
|
+
console=ui_console,
|
|
3997
|
+
)
|
|
3998
|
+
continue
|
|
3999
|
+
if text == "/reload":
|
|
4000
|
+
_dispatch_reload_command(
|
|
4001
|
+
config=config,
|
|
4002
|
+
permission=permission,
|
|
4003
|
+
ui_console=ui_console,
|
|
4004
|
+
)
|
|
4005
|
+
last_config_mtimes = _config_mtimes(config)
|
|
4006
|
+
continue
|
|
4007
|
+
if text.startswith("/mcp:"):
|
|
4008
|
+
snapshot_len = len(messages)
|
|
4009
|
+
appended = _dispatch_mcp_prompt(
|
|
4010
|
+
text,
|
|
4011
|
+
mcp_manager=mcp_manager,
|
|
4012
|
+
messages=messages,
|
|
4013
|
+
ui_console=ui_console,
|
|
4014
|
+
)
|
|
4015
|
+
if not appended:
|
|
4016
|
+
continue
|
|
4017
|
+
# Re-render the injected user turn(s) so the screen matches the
|
|
4018
|
+
# transcript before agent_loop runs.
|
|
4019
|
+
_replay_stdio_transcript(messages[snapshot_len:], ui_console)
|
|
4020
|
+
if messages[-1].get("role") != "user":
|
|
4021
|
+
# Trailing assistant message — no LLM call needed; prompt for input.
|
|
4022
|
+
ui_console.print_status("Prompt injected. Type your follow-up to continue.")
|
|
4023
|
+
continue
|
|
4024
|
+
try:
|
|
4025
|
+
agent_loop(
|
|
4026
|
+
provider=provider,
|
|
4027
|
+
messages=messages,
|
|
4028
|
+
tools=loop_tools,
|
|
4029
|
+
handlers=handlers,
|
|
4030
|
+
permission=permission,
|
|
4031
|
+
compact_fn=compact_fn,
|
|
4032
|
+
bg_manager=bg_manager,
|
|
4033
|
+
stream=config.ui.stream,
|
|
4034
|
+
console=ui_console,
|
|
4035
|
+
interaction_logger=interaction_logger,
|
|
4036
|
+
token_tracker=token_tracker,
|
|
4037
|
+
hook_engine=hook_engine,
|
|
4038
|
+
retry_policy=retry_policy,
|
|
4039
|
+
)
|
|
4040
|
+
_save_transcript_snapshot(transcript_mgr, messages, compact_fn)
|
|
4041
|
+
main_mailbox_cursor = _drain_team_mailbox(
|
|
4042
|
+
ui_console,
|
|
4043
|
+
message_bus=message_bus,
|
|
4044
|
+
since=main_mailbox_cursor,
|
|
4045
|
+
sink=pending_team_messages,
|
|
4046
|
+
)
|
|
4047
|
+
except LLMCallError:
|
|
4048
|
+
del messages[snapshot_len:]
|
|
4049
|
+
ui_console.print_error("LLM call failed, please try again.")
|
|
4050
|
+
except KeyboardInterrupt:
|
|
4051
|
+
del messages[snapshot_len:]
|
|
4052
|
+
ui_console.print_status("Agent loop interrupted.")
|
|
4053
|
+
continue
|
|
4054
|
+
if text == "/remember" or text.startswith("/remember "):
|
|
4055
|
+
if memory_manager is None:
|
|
4056
|
+
ui_console.print_error(
|
|
4057
|
+
"Persistent memory is disabled (enable [memory] in config)."
|
|
4058
|
+
)
|
|
4059
|
+
continue
|
|
4060
|
+
_, _, remember_arg = text.partition(" ")
|
|
4061
|
+
# Rewrite into an LLM instruction and fall through to the
|
|
4062
|
+
# normal user-turn handling below, which runs agent_loop.
|
|
4063
|
+
text = build_remember_instruction(remember_arg.strip())
|
|
4064
|
+
elif text == "/forget" or text.startswith("/forget "):
|
|
4065
|
+
if memory_manager is None:
|
|
4066
|
+
ui_console.print_error(
|
|
4067
|
+
"Persistent memory is disabled (enable [memory] in config)."
|
|
4068
|
+
)
|
|
4069
|
+
continue
|
|
4070
|
+
_, _, forget_arg = text.partition(" ")
|
|
4071
|
+
text = build_forget_instruction(forget_arg.strip())
|
|
4072
|
+
|
|
4073
|
+
# Surface any finished background workflows into the buffer (idempotent;
|
|
4074
|
+
# also prints a console status line) before consuming it.
|
|
4075
|
+
_drain_workflow_results(
|
|
4076
|
+
ui_console, registry=workflow_registry, sink=pending_workflow_messages
|
|
4077
|
+
)
|
|
4078
|
+
|
|
4079
|
+
# Prepend any buffered late/unsolicited teammate replies and finished
|
|
4080
|
+
# background-workflow summaries onto this user turn so the LLM sees
|
|
4081
|
+
# them (without injecting standalone user messages that would break
|
|
4082
|
+
# role alternation).
|
|
4083
|
+
pending_context = pending_team_messages + pending_workflow_messages
|
|
4084
|
+
if pending_context:
|
|
4085
|
+
prepended = "\n".join(pending_context)
|
|
4086
|
+
pending_team_messages.clear()
|
|
4087
|
+
pending_workflow_messages.clear()
|
|
4088
|
+
text = f"{prepended}\n\n{text}" if text else prepended
|
|
4089
|
+
|
|
4090
|
+
messages.append({"role": "user", "content": text})
|
|
4091
|
+
snapshot_len = len(messages) - 1
|
|
4092
|
+
try:
|
|
4093
|
+
agent_loop(
|
|
4094
|
+
provider=provider,
|
|
4095
|
+
messages=messages,
|
|
4096
|
+
tools=loop_tools,
|
|
4097
|
+
handlers=handlers,
|
|
4098
|
+
permission=permission,
|
|
4099
|
+
compact_fn=compact_fn,
|
|
4100
|
+
bg_manager=bg_manager,
|
|
4101
|
+
stream=config.ui.stream,
|
|
4102
|
+
console=ui_console,
|
|
4103
|
+
interaction_logger=interaction_logger,
|
|
4104
|
+
token_tracker=token_tracker,
|
|
4105
|
+
hook_engine=hook_engine,
|
|
4106
|
+
retry_policy=retry_policy,
|
|
4107
|
+
skill_gen=skill_generator,
|
|
4108
|
+
)
|
|
4109
|
+
_save_transcript_snapshot(transcript_mgr, messages, compact_fn)
|
|
4110
|
+
# Experiential skill generation: when this turn pushed the
|
|
4111
|
+
# cumulative activity past both thresholds, reflect on the
|
|
4112
|
+
# session and draft a reusable skill (isolated extra LLM call).
|
|
4113
|
+
# Reset first so the trigger does not re-fire every later turn.
|
|
4114
|
+
if skill_generator is not None and skill_generator.should_draft():
|
|
4115
|
+
skill_generator.reset()
|
|
4116
|
+
_run_skill_reflection(
|
|
4117
|
+
provider=provider,
|
|
4118
|
+
messages=messages,
|
|
4119
|
+
store=skill_store,
|
|
4120
|
+
skill_loader=skill_loader,
|
|
4121
|
+
console=ui_console,
|
|
4122
|
+
token_tracker=token_tracker,
|
|
4123
|
+
permission=permission,
|
|
4124
|
+
max_pending=config.skills.max_pending,
|
|
4125
|
+
)
|
|
4126
|
+
main_mailbox_cursor = _drain_team_mailbox(
|
|
4127
|
+
ui_console,
|
|
4128
|
+
message_bus=message_bus,
|
|
4129
|
+
since=main_mailbox_cursor,
|
|
4130
|
+
sink=pending_team_messages,
|
|
4131
|
+
)
|
|
4132
|
+
except LLMCallError:
|
|
4133
|
+
del messages[snapshot_len:]
|
|
4134
|
+
ui_console.print_error("LLM call failed, please try again.")
|
|
4135
|
+
except KeyboardInterrupt:
|
|
4136
|
+
del messages[snapshot_len:]
|
|
4137
|
+
ui_console.print_status("Agent loop interrupted.")
|
|
4138
|
+
finally:
|
|
4139
|
+
try:
|
|
4140
|
+
scheduler.cancel_all()
|
|
4141
|
+
except Exception:
|
|
4142
|
+
pass
|
|
4143
|
+
try:
|
|
4144
|
+
mcp_manager.close_all()
|
|
4145
|
+
except Exception:
|
|
4146
|
+
pass
|
|
4147
|
+
try:
|
|
4148
|
+
lsp_manager.close_all()
|
|
4149
|
+
except Exception:
|
|
4150
|
+
pass
|
|
4151
|
+
|
|
4152
|
+
|
|
4153
|
+
def main(argv: list[str] | None = None) -> int:
|
|
4154
|
+
args = parse_args(argv)
|
|
4155
|
+
config_path = resolve_config_path(args.config)
|
|
4156
|
+
|
|
4157
|
+
if getattr(args, "command", None) == "init":
|
|
4158
|
+
return 0 if run_setup_wizard(config_path=config_path) else 1
|
|
4159
|
+
|
|
4160
|
+
try:
|
|
4161
|
+
config = load_config(
|
|
4162
|
+
config_path,
|
|
4163
|
+
provider_override=args.provider,
|
|
4164
|
+
model_override=args.model,
|
|
4165
|
+
)
|
|
4166
|
+
except FileNotFoundError:
|
|
4167
|
+
print(f"Config file not found: {config_path}")
|
|
4168
|
+
return 1
|
|
4169
|
+
except (tomllib.TOMLDecodeError, ValueError) as exc:
|
|
4170
|
+
print(f"Failed to load config: {exc}")
|
|
4171
|
+
return 1
|
|
4172
|
+
|
|
4173
|
+
# First-run convenience: when no usable API key is configured and we are on
|
|
4174
|
+
# an interactive terminal, drop into the same setup wizard rather than
|
|
4175
|
+
# failing later in ``create_provider``. Non-TTY runs keep the existing
|
|
4176
|
+
# fail-fast behaviour below.
|
|
4177
|
+
provider_config = getattr(config, "provider", None)
|
|
4178
|
+
if (
|
|
4179
|
+
isinstance(provider_config, ProviderConfig)
|
|
4180
|
+
and not _has_usable_key(provider_config)
|
|
4181
|
+
and sys.stdin.isatty()
|
|
4182
|
+
):
|
|
4183
|
+
print("No usable API key detected. Entering interactive setup...")
|
|
4184
|
+
if run_setup_wizard(config_path=config_path):
|
|
4185
|
+
try:
|
|
4186
|
+
config = load_config(
|
|
4187
|
+
config_path,
|
|
4188
|
+
provider_override=args.provider,
|
|
4189
|
+
model_override=args.model,
|
|
4190
|
+
)
|
|
4191
|
+
except (FileNotFoundError, tomllib.TOMLDecodeError, ValueError) as exc:
|
|
4192
|
+
print(f"Failed to reload config after setup: {exc}")
|
|
4193
|
+
return 1
|
|
4194
|
+
|
|
4195
|
+
try:
|
|
4196
|
+
provider = create_provider(config)
|
|
4197
|
+
except ValueError as exc:
|
|
4198
|
+
print(f"Failed to initialize provider: {exc}")
|
|
4199
|
+
return 1
|
|
4200
|
+
|
|
4201
|
+
return _run_stdio_session(config, provider)
|
|
4202
|
+
|
|
4203
|
+
|
|
4204
|
+
if __name__ == "__main__":
|
|
4205
|
+
raise SystemExit(main())
|