beamlab 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.
Potentially problematic release.
This version of beamlab might be problematic. Click here for more details.
- beamlab/__init__.py +24 -0
- beamlab/agent/__init__.py +1 -0
- beamlab/agent/context.py +223 -0
- beamlab/agent/controller.py +279 -0
- beamlab/agent/memory/__init__.py +1 -0
- beamlab/agent/memory/active_context.py +27 -0
- beamlab/agent/memory/chunker.py +177 -0
- beamlab/agent/memory/session.py +274 -0
- beamlab/agent/memory/vector.py +179 -0
- beamlab/agent/validator.py +125 -0
- beamlab/cli/__init__.py +1 -0
- beamlab/cli/commands.py +239 -0
- beamlab/cli/renderer.py +210 -0
- beamlab/cli/session_picker.py +138 -0
- beamlab/cli/shell.py +205 -0
- beamlab/config.py +215 -0
- beamlab/intelligence/__init__.py +1 -0
- beamlab/intelligence/repo_walker.py +84 -0
- beamlab/intelligence/symbol_index.py +223 -0
- beamlab/llm/__init__.py +1 -0
- beamlab/llm/orchestrator.py +287 -0
- beamlab/llm/providers.py +531 -0
- beamlab/main.py +201 -0
- beamlab/prompts/gemini-cli-system-prompt.md +209 -0
- beamlab/prompts/system-prompt.md +213 -0
- beamlab/prompts/system_prompt.md +59 -0
- beamlab/prompts/tools/claude-code/Bash.md +127 -0
- beamlab/prompts/tools/claude-code/Edit.md +37 -0
- beamlab/prompts/tools/claude-code/Glob.md +25 -0
- beamlab/prompts/tools/claude-code/Grep.md +63 -0
- beamlab/prompts/tools/claude-code/LS.md +22 -0
- beamlab/prompts/tools/claude-code/MultiEdit.md +80 -0
- beamlab/prompts/tools/claude-code/NotebookEdit.md +37 -0
- beamlab/prompts/tools/claude-code/NotebookRead.md +20 -0
- beamlab/prompts/tools/claude-code/Read.md +37 -0
- beamlab/prompts/tools/claude-code/Task.md +42 -0
- beamlab/prompts/tools/claude-code/TodoWrite.md +18 -0
- beamlab/prompts/tools/claude-code/WebFetch.md +36 -0
- beamlab/prompts/tools/claude-code/WebSearch.md +36 -0
- beamlab/prompts/tools/claude-code/Write.md +28 -0
- beamlab/prompts/tools/claude-code/exit-plan-mode.md +22 -0
- beamlab/prompts/tools/gemini-cli/EditTool.md +40 -0
- beamlab/prompts/tools/gemini-cli/GlobTool.md +41 -0
- beamlab/prompts/tools/gemini-cli/GrepTool.md +35 -0
- beamlab/prompts/tools/gemini-cli/LSTool.md +39 -0
- beamlab/prompts/tools/gemini-cli/MemoryTool.md +27 -0
- beamlab/prompts/tools/gemini-cli/ReadFileTool.md +36 -0
- beamlab/prompts/tools/gemini-cli/ReadManyFilesTool.md +59 -0
- beamlab/prompts/tools/gemini-cli/ShellTool.md +35 -0
- beamlab/prompts/tools/gemini-cli/WebFetchTool.md +27 -0
- beamlab/prompts/tools/gemini-cli/WebSearchTool.md +27 -0
- beamlab/prompts/tools/gemini-cli/WriteFileTool.md +35 -0
- beamlab/safety/__init__.py +1 -0
- beamlab/safety/guardrails.py +171 -0
- beamlab/tools/__init__.py +1 -0
- beamlab/tools/diff_patch.py +74 -0
- beamlab/tools/file_io.py +406 -0
- beamlab/tools/memory.py +78 -0
- beamlab/tools/registry.py +98 -0
- beamlab/tools/search.py +172 -0
- beamlab/tools/terminal.py +94 -0
- beamlab/tools/test_runner.py +108 -0
- beamlab/tools/todo.py +124 -0
- beamlab/tools/tool_schemas.py +230 -0
- beamlab/tools/web.py +137 -0
- beamlab-0.1.0.dist-info/METADATA +80 -0
- beamlab-0.1.0.dist-info/RECORD +69 -0
- beamlab-0.1.0.dist-info/WHEEL +4 -0
- beamlab-0.1.0.dist-info/entry_points.txt +2 -0
beamlab/__init__.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""
|
|
2
|
+
beam/__init__.py — Package root.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import os as _os
|
|
6
|
+
import warnings as _warnings
|
|
7
|
+
|
|
8
|
+
# Suppress HuggingFace / transformers / sentence-transformers noise.
|
|
9
|
+
# Must be set BEFORE any of those packages are imported.
|
|
10
|
+
_os.environ.setdefault("HF_HUB_DISABLE_PROGRESS_BARS", "1")
|
|
11
|
+
_os.environ.setdefault("TRANSFORMERS_NO_ADVISORY_WARNINGS", "1")
|
|
12
|
+
_os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
|
|
13
|
+
_os.environ.setdefault("HF_HUB_DISABLE_TELEMETRY", "1")
|
|
14
|
+
_os.environ.setdefault("SAFETENSORS_FAST_GPU", "0")
|
|
15
|
+
# Suppress the "BertModel LOAD REPORT" and "unauthenticated requests" warnings
|
|
16
|
+
_warnings.filterwarnings("ignore", message=".*unauthenticated.*")
|
|
17
|
+
_warnings.filterwarnings("ignore", message=".*LOAD REPORT.*")
|
|
18
|
+
|
|
19
|
+
import logging as _logging
|
|
20
|
+
for _name in ("huggingface_hub", "transformers", "sentence_transformers", "safetensors"):
|
|
21
|
+
_logging.getLogger(_name).setLevel(_logging.ERROR)
|
|
22
|
+
|
|
23
|
+
__version__ = "0.1.0"
|
|
24
|
+
__app_name__ = "beamlab"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# beam/agent/__init__.py
|
beamlab/agent/context.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""
|
|
2
|
+
beam/agent/context.py — Context Engineering: builds the full ContextPacket.
|
|
3
|
+
|
|
4
|
+
The ContextPacket is everything the LLM receives per-turn:
|
|
5
|
+
- system prompt (identity, rules, tool schemas)
|
|
6
|
+
- conversation history (trimmed to budget)
|
|
7
|
+
- retrieved code chunks (RAG, populated in Phase 4)
|
|
8
|
+
- workspace state (open files, cwd, OS)
|
|
9
|
+
|
|
10
|
+
This module is intentionally stateless — it just assembles and returns.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import platform
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
# Data structures
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class WorkspaceState:
|
|
27
|
+
"""Snapshot of the user's current working environment."""
|
|
28
|
+
cwd: str = ""
|
|
29
|
+
os: str = ""
|
|
30
|
+
python_version: str = ""
|
|
31
|
+
shell: str = ""
|
|
32
|
+
git_branch: str = "unknown"
|
|
33
|
+
git_status: str = "unknown"
|
|
34
|
+
active_task: str = "None"
|
|
35
|
+
session_id: str = "latest"
|
|
36
|
+
current_time: str = ""
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def capture(cls) -> "WorkspaceState":
|
|
40
|
+
import sys
|
|
41
|
+
import os as _os
|
|
42
|
+
import subprocess
|
|
43
|
+
|
|
44
|
+
shell = _os.environ.get("SHELL", _os.environ.get("COMSPEC", "unknown"))
|
|
45
|
+
branch = "unknown"
|
|
46
|
+
status = "unknown"
|
|
47
|
+
try:
|
|
48
|
+
branch = subprocess.check_output(
|
|
49
|
+
["git", "branch", "--show-current"], stderr=subprocess.DEVNULL, text=True
|
|
50
|
+
).strip()
|
|
51
|
+
status = subprocess.check_output(
|
|
52
|
+
["git", "status", "--short"], stderr=subprocess.DEVNULL, text=True
|
|
53
|
+
).strip() or "clean"
|
|
54
|
+
except Exception:
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
from datetime import datetime
|
|
58
|
+
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
59
|
+
|
|
60
|
+
return cls(
|
|
61
|
+
cwd=str(Path.cwd()),
|
|
62
|
+
os=platform.system(),
|
|
63
|
+
python_version=sys.version.split()[0],
|
|
64
|
+
shell=shell,
|
|
65
|
+
git_branch=branch,
|
|
66
|
+
git_status=status,
|
|
67
|
+
current_time=now,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class ContextPacket:
|
|
73
|
+
"""Everything assembled for a single LLM call."""
|
|
74
|
+
system_prompt: str
|
|
75
|
+
messages: list[dict[str, str]] # OpenAI message format
|
|
76
|
+
workspace: WorkspaceState = field(default_factory=WorkspaceState.capture)
|
|
77
|
+
token_estimate: int = 0 # rough estimate, updated by assembler
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
# System prompt template
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
def _load_system_prompt_template() -> str:
|
|
85
|
+
prompt_path = Path(__file__).parent.parent / "prompts" / "system_prompt.md"
|
|
86
|
+
try:
|
|
87
|
+
return prompt_path.read_text(encoding="utf-8")
|
|
88
|
+
except Exception as e:
|
|
89
|
+
return f"System prompt loading failed: {e}\\n\\nOS: {{os}}\\nCWD: {{cwd}}\\nPython: {{python_version}}\\nTools: {{tool_list}}"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ---------------------------------------------------------------------------
|
|
93
|
+
# Assembler
|
|
94
|
+
# ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
class ContextAssembler:
|
|
97
|
+
"""
|
|
98
|
+
Builds a ContextPacket for the LLM.
|
|
99
|
+
|
|
100
|
+
Token budget (approximate, based on 4 chars/token):
|
|
101
|
+
- System prompt: ~2K tokens
|
|
102
|
+
- History: ~20K tokens
|
|
103
|
+
- Code chunks: ~30K tokens (Phase 4 — RAG)
|
|
104
|
+
- Output reserve: ~8K tokens
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
HISTORY_CHAR_LIMIT = 80_000 # ~20K tokens
|
|
108
|
+
CHUNKS_CHAR_LIMIT = 120_000 # ~30K tokens
|
|
109
|
+
|
|
110
|
+
def __init__(self, tools: list[str] | None = None, model_config: Any = None) -> None:
|
|
111
|
+
# Tools list will be populated in Phase 5 from the tool registry
|
|
112
|
+
self._tools = tools or []
|
|
113
|
+
self._model_config = model_config
|
|
114
|
+
|
|
115
|
+
def build(
|
|
116
|
+
self,
|
|
117
|
+
user_message: str,
|
|
118
|
+
history: list[dict[str, str]],
|
|
119
|
+
code_chunks: list[str] | None = None,
|
|
120
|
+
) -> ContextPacket:
|
|
121
|
+
"""
|
|
122
|
+
Assemble a ContextPacket for this turn.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
user_message: The user's latest input.
|
|
126
|
+
history: Previous turns in OpenAI message format.
|
|
127
|
+
code_chunks: Relevant code snippets from RAG (Phase 4).
|
|
128
|
+
"""
|
|
129
|
+
workspace = WorkspaceState.capture()
|
|
130
|
+
system_prompt = self._build_system_prompt(workspace)
|
|
131
|
+
messages = self._build_messages(user_message, history, code_chunks or [])
|
|
132
|
+
token_estimate = sum(len(m["content"]) for m in messages) // 4
|
|
133
|
+
|
|
134
|
+
return ContextPacket(
|
|
135
|
+
system_prompt=system_prompt,
|
|
136
|
+
messages=messages,
|
|
137
|
+
workspace=workspace,
|
|
138
|
+
token_estimate=token_estimate,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
def _build_system_prompt(self, workspace: WorkspaceState) -> str:
|
|
142
|
+
from beamlab.tools.registry import registry
|
|
143
|
+
tool_list = registry.tool_list_for_prompt()
|
|
144
|
+
|
|
145
|
+
# ── Thinking Protocol ─────────────────────────────────────────
|
|
146
|
+
thinking_protocol = ""
|
|
147
|
+
if self._model_config and hasattr(self._model_config, "is_thinking_model"):
|
|
148
|
+
if self._model_config.is_thinking_model():
|
|
149
|
+
thinking_protocol = (
|
|
150
|
+
"### 🧠 THINKING PROTOCOL\n"
|
|
151
|
+
"- Before any tool call or complex response, you MUST perform an internal reasoning step.\n"
|
|
152
|
+
"- Wrap your internal monologue in `<thinking>` tags.\n"
|
|
153
|
+
"- Analyze the problem, identify edge cases, and verify your logic *before* acting.\n"
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
class SafeDict(dict):
|
|
157
|
+
def __missing__(self, key):
|
|
158
|
+
return f"{{{key}}}"
|
|
159
|
+
|
|
160
|
+
template = _load_system_prompt_template()
|
|
161
|
+
return template.format_map(SafeDict(
|
|
162
|
+
os=workspace.os,
|
|
163
|
+
cwd=workspace.cwd,
|
|
164
|
+
python_version=workspace.python_version,
|
|
165
|
+
shell=workspace.shell,
|
|
166
|
+
git_branch=workspace.git_branch,
|
|
167
|
+
git_status=workspace.git_status,
|
|
168
|
+
active_task=workspace.active_task,
|
|
169
|
+
session_id=workspace.session_id,
|
|
170
|
+
current_time=workspace.current_time,
|
|
171
|
+
tool_list=tool_list,
|
|
172
|
+
thinking_protocol=thinking_protocol,
|
|
173
|
+
))
|
|
174
|
+
|
|
175
|
+
def _build_messages(
|
|
176
|
+
self,
|
|
177
|
+
user_message: str,
|
|
178
|
+
history: list[dict[str, str]],
|
|
179
|
+
code_chunks: list[str],
|
|
180
|
+
) -> list[dict[str, str]]:
|
|
181
|
+
messages: list[dict[str, str]] = []
|
|
182
|
+
|
|
183
|
+
# Trim history to stay within token budget
|
|
184
|
+
trimmed_history = _trim_to_char_limit(history, self.HISTORY_CHAR_LIMIT)
|
|
185
|
+
messages.extend(trimmed_history)
|
|
186
|
+
|
|
187
|
+
# Inject relevant code chunks as context (before the user message)
|
|
188
|
+
if code_chunks:
|
|
189
|
+
chunks_text = _trim_to_char_limit(
|
|
190
|
+
[{"role": "user", "content": c} for c in code_chunks],
|
|
191
|
+
self.CHUNKS_CHAR_LIMIT,
|
|
192
|
+
)
|
|
193
|
+
# Merge all chunks into one context message
|
|
194
|
+
combined = "\n\n---\n\n".join(m["content"] for m in chunks_text)
|
|
195
|
+
messages.append({
|
|
196
|
+
"role": "user",
|
|
197
|
+
"content": f"[CONTEXT — relevant code from your codebase]\n\n{combined}",
|
|
198
|
+
})
|
|
199
|
+
messages.append({"role": "assistant", "content": "Understood, I have the context."})
|
|
200
|
+
|
|
201
|
+
# The actual user turn
|
|
202
|
+
messages.append({"role": "user", "content": user_message})
|
|
203
|
+
return messages
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# ---------------------------------------------------------------------------
|
|
207
|
+
# Helpers
|
|
208
|
+
# ---------------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
def _trim_to_char_limit(
|
|
211
|
+
messages: list[dict[str, str]],
|
|
212
|
+
char_limit: int,
|
|
213
|
+
) -> list[dict[str, str]]:
|
|
214
|
+
"""
|
|
215
|
+
Drop oldest messages until total character count is within limit.
|
|
216
|
+
Always keeps at least the most recent message.
|
|
217
|
+
"""
|
|
218
|
+
total = sum(len(m["content"]) for m in messages)
|
|
219
|
+
result = list(messages)
|
|
220
|
+
while total > char_limit and len(result) > 1:
|
|
221
|
+
removed = result.pop(0)
|
|
222
|
+
total -= len(removed["content"])
|
|
223
|
+
return result
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""
|
|
2
|
+
beam/agent/controller.py — Core agent loop (ReAct / tool-calling pattern).
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
import re
|
|
11
|
+
import time
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import AsyncIterator, Any
|
|
14
|
+
|
|
15
|
+
from beamlab.agent.context import ContextAssembler
|
|
16
|
+
from beamlab.agent.validator import Validator
|
|
17
|
+
from beamlab.config import AgentConfig
|
|
18
|
+
from beamlab.llm.orchestrator import Orchestrator, OrchestratorError
|
|
19
|
+
from beamlab.tools.tool_schemas import get_tool_schemas
|
|
20
|
+
from beamlab.safety.guardrails import Guardrails
|
|
21
|
+
|
|
22
|
+
_INPUT_CHAR_LIMIT = 50_000
|
|
23
|
+
_RAG_TOP_K = 6
|
|
24
|
+
_MAX_TURNS = 15
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AgentController:
|
|
30
|
+
"""
|
|
31
|
+
One instance per session. Call run() for each user turn.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
config: AgentConfig,
|
|
37
|
+
orchestrator: Orchestrator,
|
|
38
|
+
tool_registry: Any, # ToolRegistry
|
|
39
|
+
project_root: Path | str = ".",
|
|
40
|
+
vector_store=None,
|
|
41
|
+
initial_history: list[dict] | None = None,
|
|
42
|
+
session: Any = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
self._config = config
|
|
45
|
+
self._orch = orchestrator
|
|
46
|
+
self._tool_registry = tool_registry
|
|
47
|
+
self._history: list[dict] = initial_history or []
|
|
48
|
+
self._project_root = Path(project_root).resolve()
|
|
49
|
+
self._vector_store = vector_store
|
|
50
|
+
self._session = session
|
|
51
|
+
|
|
52
|
+
self._context = ContextAssembler(tools=tool_registry.all_names())
|
|
53
|
+
self._validator = Validator(self._project_root)
|
|
54
|
+
self._tool_schemas = get_tool_schemas(tool_registry.all_names())
|
|
55
|
+
self._active_plan: list[str] = []
|
|
56
|
+
self._full_plan_size: int = 0
|
|
57
|
+
|
|
58
|
+
# HITL State
|
|
59
|
+
self.auto_approve = False
|
|
60
|
+
self._guardrails = Guardrails(config.safety)
|
|
61
|
+
|
|
62
|
+
async def initialize(self):
|
|
63
|
+
"""Must be called after tools are registered."""
|
|
64
|
+
# Wrap tools with safety logic, passing this controller for HITL
|
|
65
|
+
await self._guardrails.wrap_registry(self._tool_registry, self)
|
|
66
|
+
|
|
67
|
+
# ------------------------------------------------------------------
|
|
68
|
+
# HITL Interface
|
|
69
|
+
# ------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
async def request_approval(self, name: str, params: dict) -> bool:
|
|
72
|
+
"""
|
|
73
|
+
Called by Guardrails (safe_fn).
|
|
74
|
+
Yields a PROMPT to the renderer and waits for asend() response.
|
|
75
|
+
"""
|
|
76
|
+
prompt_msg = f"Approve {name}?"
|
|
77
|
+
if name == "run_command":
|
|
78
|
+
prompt_msg = f"Execute: {params.get('command', '')}"
|
|
79
|
+
elif name == "write_file":
|
|
80
|
+
prompt_msg = f"Write to: {params.get('path', '')}"
|
|
81
|
+
|
|
82
|
+
# This is a bit tricky: Guardrails is inside _execute_tool,
|
|
83
|
+
# which is inside run(). We need to reach the 'yield' in run().
|
|
84
|
+
# We raise a special exception that run() catches to perform the yield.
|
|
85
|
+
raise NeedsApproval(name, prompt_msg)
|
|
86
|
+
|
|
87
|
+
# ------------------------------------------------------------------
|
|
88
|
+
# Main entry point
|
|
89
|
+
# ------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
async def run(self, user_message: str) -> AsyncIterator[str]:
|
|
92
|
+
"""Process one user turn. Yields protocol lines for the renderer."""
|
|
93
|
+
from beamlab.agent.memory.active_context import set_active_session, set_active_vector_store
|
|
94
|
+
set_active_session(self._session)
|
|
95
|
+
set_active_vector_store(self._vector_store)
|
|
96
|
+
|
|
97
|
+
user_message = user_message.strip()
|
|
98
|
+
if not user_message: return
|
|
99
|
+
|
|
100
|
+
# ── Context & History ─────────────────────────────────────────
|
|
101
|
+
code_chunks: list[str] = []
|
|
102
|
+
if self._vector_store and len(self._vector_store) > 0:
|
|
103
|
+
try:
|
|
104
|
+
results = self._vector_store.search(user_message, top_k=_RAG_TOP_K)
|
|
105
|
+
code_chunks = [r.chunk.text for r in results]
|
|
106
|
+
except Exception: pass
|
|
107
|
+
|
|
108
|
+
self._context._model_config = self._config.model
|
|
109
|
+
ctx = self._context.build(user_message, self._history, code_chunks=code_chunks)
|
|
110
|
+
|
|
111
|
+
messages: list[dict] = [
|
|
112
|
+
{"role": "system", "content": ctx.system_prompt},
|
|
113
|
+
*self._history[-10:],
|
|
114
|
+
{"role": "user", "content": user_message},
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
yield "STATUS:thinking\n"
|
|
118
|
+
start_time = time.time()
|
|
119
|
+
tool_call_count = 0
|
|
120
|
+
consecutive_tool_failures = 0
|
|
121
|
+
last_failed_tool = None
|
|
122
|
+
|
|
123
|
+
for turn in range(_MAX_TURNS):
|
|
124
|
+
yield "STATUS:thinking\n"
|
|
125
|
+
try:
|
|
126
|
+
stream = self._orch.chat_with_tools_stream(messages, tools=self._tool_schemas)
|
|
127
|
+
response = None
|
|
128
|
+
combined_text = ""
|
|
129
|
+
|
|
130
|
+
async for chunk in stream:
|
|
131
|
+
if isinstance(chunk, str):
|
|
132
|
+
combined_text += chunk
|
|
133
|
+
# Escape newlines to avoid breaking the line-based protocol
|
|
134
|
+
safe_chunk = chunk.replace("\n", "<BR>")
|
|
135
|
+
yield f"TEXT:{safe_chunk}\n"
|
|
136
|
+
else:
|
|
137
|
+
response = chunk
|
|
138
|
+
|
|
139
|
+
if turn == 0:
|
|
140
|
+
elapsed = int(time.time() - start_time)
|
|
141
|
+
yield f"METADATA:thought_seconds:{elapsed}\n"
|
|
142
|
+
|
|
143
|
+
except OrchestratorError as e:
|
|
144
|
+
yield f"ERR:{e}\n"
|
|
145
|
+
break
|
|
146
|
+
|
|
147
|
+
if response is None:
|
|
148
|
+
if combined_text:
|
|
149
|
+
yield "STATUS:responding\n"
|
|
150
|
+
self._history.append({"role": "user", "content": user_message})
|
|
151
|
+
self._history.append({"role": "assistant", "content": combined_text})
|
|
152
|
+
yield "STATUS:done\n"
|
|
153
|
+
return
|
|
154
|
+
break
|
|
155
|
+
|
|
156
|
+
# ── Check for Plan ──
|
|
157
|
+
plan_match = re.search(r"<plan>([\s\S]*?)</plan>", combined_text)
|
|
158
|
+
if plan_match and not self._active_plan:
|
|
159
|
+
lines = [s.strip() for s in plan_match.group(1).split("\n") if s.strip()]
|
|
160
|
+
self._active_plan = [re.sub(r"^\d+[\.\)]\s*", "", s) for s in lines]
|
|
161
|
+
self._full_plan_size = len(self._active_plan)
|
|
162
|
+
yield f"PLAN:{'|'.join(self._active_plan)}\n"
|
|
163
|
+
yield "PLAN_PTR:0\n"
|
|
164
|
+
yield "STATUS:planning\n"
|
|
165
|
+
|
|
166
|
+
# ── Process Step Completion ──
|
|
167
|
+
if not response.has_tool_calls:
|
|
168
|
+
if self._active_plan:
|
|
169
|
+
self._active_plan.pop(0)
|
|
170
|
+
yield f"PLAN_PTR:{self._full_plan_size - len(self._active_plan)}\n"
|
|
171
|
+
|
|
172
|
+
if not self._active_plan:
|
|
173
|
+
yield "STATUS:responding\n"
|
|
174
|
+
self._history.append({"role": "user", "content": user_message})
|
|
175
|
+
self._history.append({"role": "assistant", "content": combined_text})
|
|
176
|
+
yield "STATUS:done\n"
|
|
177
|
+
break
|
|
178
|
+
else:
|
|
179
|
+
messages.append({"role": "assistant", "content": combined_text})
|
|
180
|
+
messages.append({"role": "user", "content": f"Proceeding to next step: {self._active_plan[0]}"})
|
|
181
|
+
continue
|
|
182
|
+
|
|
183
|
+
# ── Execute Tools ─────────────────────────────────────────
|
|
184
|
+
yield "STATUS:executing\n"
|
|
185
|
+
assistant_msg = {
|
|
186
|
+
"role": "assistant",
|
|
187
|
+
"content": response.text or None,
|
|
188
|
+
"tool_calls": [
|
|
189
|
+
{
|
|
190
|
+
"id": tc.id,
|
|
191
|
+
"type": "function",
|
|
192
|
+
"function": {"name": tc.name, "arguments": json.dumps(tc.arguments)},
|
|
193
|
+
}
|
|
194
|
+
for tc in response.tool_calls if tc.name # DEFENSIVE
|
|
195
|
+
]
|
|
196
|
+
}
|
|
197
|
+
messages.append(assistant_msg)
|
|
198
|
+
|
|
199
|
+
for tc in response.tool_calls:
|
|
200
|
+
if not tc.name: continue # KILL ARTIFACTS
|
|
201
|
+
tool_call_count += 1
|
|
202
|
+
yield f"TASK:t{tool_call_count:03d}:{tc.name}({_summarize_args(tc.arguments)}):{tc.name}\n"
|
|
203
|
+
|
|
204
|
+
# HITL Loop for this tool
|
|
205
|
+
result = ""
|
|
206
|
+
while True:
|
|
207
|
+
try:
|
|
208
|
+
result = await self._execute_tool(tc.name, tc.arguments)
|
|
209
|
+
break
|
|
210
|
+
except NeedsApproval as e:
|
|
211
|
+
# Yield the prompt and WAIT for asend() response.
|
|
212
|
+
# The renderer will call gen.asend(answer), which makes 'answer' the result of this yield.
|
|
213
|
+
answer = (yield f"PROMPT:t{tool_call_count:03d}:{e.message}\n")
|
|
214
|
+
|
|
215
|
+
if answer == "a": # All
|
|
216
|
+
self.auto_approve = True
|
|
217
|
+
continue # Retry tool with global auto-approve
|
|
218
|
+
elif answer in ("y", "yes"):
|
|
219
|
+
# Temporary override for current tool instance
|
|
220
|
+
self._temp_approved = True
|
|
221
|
+
continue
|
|
222
|
+
else:
|
|
223
|
+
result = f"Error: User declined tool call {tc.name}."
|
|
224
|
+
break
|
|
225
|
+
|
|
226
|
+
short = result[:300].replace("\n", " ")
|
|
227
|
+
is_error = result.startswith("Error:") or "[VALIDATE FAILED]" in result
|
|
228
|
+
yield f"RESULT:t{tool_call_count:03d}:{'err' if is_error else 'ok'}:{short}\n"
|
|
229
|
+
|
|
230
|
+
if is_error:
|
|
231
|
+
consecutive_tool_failures += 1
|
|
232
|
+
if consecutive_tool_failures >= 2:
|
|
233
|
+
# Inject a recovery hint into the next turn
|
|
234
|
+
result += "\n\n[SYSTEM HINT]: Multiple tools have failed. Please run 'ls -R' or use a file-discovery tool to re-verify the workspace state before continuing."
|
|
235
|
+
consecutive_tool_failures = 0 # reset once hinted
|
|
236
|
+
else:
|
|
237
|
+
consecutive_tool_failures = 0
|
|
238
|
+
|
|
239
|
+
messages.append({
|
|
240
|
+
"role": "tool",
|
|
241
|
+
"tool_call_id": tc.id,
|
|
242
|
+
"content": result[:12000],
|
|
243
|
+
})
|
|
244
|
+
else:
|
|
245
|
+
yield "ERR:Agent reached maximum turns.\n"
|
|
246
|
+
|
|
247
|
+
async def _execute_tool(self, name: str, arguments: dict) -> str:
|
|
248
|
+
# ToolRegistry has as .get() method
|
|
249
|
+
tool_fn = self._tool_registry.get(name)
|
|
250
|
+
if not tool_fn: return f"Error: unknown tool '{name}'"
|
|
251
|
+
|
|
252
|
+
# Note: tool_fn might have been wrapped by Guardrails._wrap
|
|
253
|
+
# If so, it might raise NeedsApproval
|
|
254
|
+
if hasattr(self, "_temp_approved") and self._temp_approved:
|
|
255
|
+
# This is a bit hacky — we need to tell the wrapped fn it's approved
|
|
256
|
+
# For now, let's just use self.auto_approve or a context var
|
|
257
|
+
self._temp_approved_context = True # used by Guardrail
|
|
258
|
+
|
|
259
|
+
try:
|
|
260
|
+
return str(await tool_fn(arguments))
|
|
261
|
+
finally:
|
|
262
|
+
if hasattr(self, "_temp_approved"): del self._temp_approved
|
|
263
|
+
|
|
264
|
+
@classmethod
|
|
265
|
+
def from_config(cls, config: AgentConfig, tool_registry: Any, project_root: Path | str = ".", vector_store=None, initial_history: list[dict] | None = None, session: Any = None) -> AgentController:
|
|
266
|
+
orch = Orchestrator.from_config(config)
|
|
267
|
+
return cls(config=config, orchestrator=orch, tool_registry=tool_registry, project_root=project_root, vector_store=vector_store, initial_history=initial_history, session=session)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
class NeedsApproval(Exception):
|
|
271
|
+
def __init__(self, tool_name: str, message: str):
|
|
272
|
+
self.tool_name = tool_name
|
|
273
|
+
self.message = message
|
|
274
|
+
|
|
275
|
+
def _summarize_args(args: dict) -> str:
|
|
276
|
+
if "path" in args: return Path(args["path"]).name
|
|
277
|
+
if "query" in args: return args["query"]
|
|
278
|
+
if "command" in args: return args["command"].split(" ", 1)[0]
|
|
279
|
+
return str(args)[:40]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# beam/agent/memory/__init__.py
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""
|
|
2
|
+
beam/agent/memory/active_context.py — Global context for tool access.
|
|
3
|
+
Allows isolated tool functions to reach the current session and vector store.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from beamlab.agent.memory.session import Session
|
|
10
|
+
from beamlab.agent.memory.vector import VectorStore
|
|
11
|
+
|
|
12
|
+
_active_session: Session | None = None
|
|
13
|
+
_active_vector_store: VectorStore | None = None
|
|
14
|
+
|
|
15
|
+
def set_active_session(session: Session | None):
|
|
16
|
+
global _active_session
|
|
17
|
+
_active_session = session
|
|
18
|
+
|
|
19
|
+
def get_active_session() -> Session | None:
|
|
20
|
+
return _active_session
|
|
21
|
+
|
|
22
|
+
def set_active_vector_store(store: VectorStore | None):
|
|
23
|
+
global _active_vector_store
|
|
24
|
+
_active_vector_store = store
|
|
25
|
+
|
|
26
|
+
def get_active_vector_store() -> VectorStore | None:
|
|
27
|
+
return _active_vector_store
|