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.

Files changed (69) hide show
  1. beamlab/__init__.py +24 -0
  2. beamlab/agent/__init__.py +1 -0
  3. beamlab/agent/context.py +223 -0
  4. beamlab/agent/controller.py +279 -0
  5. beamlab/agent/memory/__init__.py +1 -0
  6. beamlab/agent/memory/active_context.py +27 -0
  7. beamlab/agent/memory/chunker.py +177 -0
  8. beamlab/agent/memory/session.py +274 -0
  9. beamlab/agent/memory/vector.py +179 -0
  10. beamlab/agent/validator.py +125 -0
  11. beamlab/cli/__init__.py +1 -0
  12. beamlab/cli/commands.py +239 -0
  13. beamlab/cli/renderer.py +210 -0
  14. beamlab/cli/session_picker.py +138 -0
  15. beamlab/cli/shell.py +205 -0
  16. beamlab/config.py +215 -0
  17. beamlab/intelligence/__init__.py +1 -0
  18. beamlab/intelligence/repo_walker.py +84 -0
  19. beamlab/intelligence/symbol_index.py +223 -0
  20. beamlab/llm/__init__.py +1 -0
  21. beamlab/llm/orchestrator.py +287 -0
  22. beamlab/llm/providers.py +531 -0
  23. beamlab/main.py +201 -0
  24. beamlab/prompts/gemini-cli-system-prompt.md +209 -0
  25. beamlab/prompts/system-prompt.md +213 -0
  26. beamlab/prompts/system_prompt.md +59 -0
  27. beamlab/prompts/tools/claude-code/Bash.md +127 -0
  28. beamlab/prompts/tools/claude-code/Edit.md +37 -0
  29. beamlab/prompts/tools/claude-code/Glob.md +25 -0
  30. beamlab/prompts/tools/claude-code/Grep.md +63 -0
  31. beamlab/prompts/tools/claude-code/LS.md +22 -0
  32. beamlab/prompts/tools/claude-code/MultiEdit.md +80 -0
  33. beamlab/prompts/tools/claude-code/NotebookEdit.md +37 -0
  34. beamlab/prompts/tools/claude-code/NotebookRead.md +20 -0
  35. beamlab/prompts/tools/claude-code/Read.md +37 -0
  36. beamlab/prompts/tools/claude-code/Task.md +42 -0
  37. beamlab/prompts/tools/claude-code/TodoWrite.md +18 -0
  38. beamlab/prompts/tools/claude-code/WebFetch.md +36 -0
  39. beamlab/prompts/tools/claude-code/WebSearch.md +36 -0
  40. beamlab/prompts/tools/claude-code/Write.md +28 -0
  41. beamlab/prompts/tools/claude-code/exit-plan-mode.md +22 -0
  42. beamlab/prompts/tools/gemini-cli/EditTool.md +40 -0
  43. beamlab/prompts/tools/gemini-cli/GlobTool.md +41 -0
  44. beamlab/prompts/tools/gemini-cli/GrepTool.md +35 -0
  45. beamlab/prompts/tools/gemini-cli/LSTool.md +39 -0
  46. beamlab/prompts/tools/gemini-cli/MemoryTool.md +27 -0
  47. beamlab/prompts/tools/gemini-cli/ReadFileTool.md +36 -0
  48. beamlab/prompts/tools/gemini-cli/ReadManyFilesTool.md +59 -0
  49. beamlab/prompts/tools/gemini-cli/ShellTool.md +35 -0
  50. beamlab/prompts/tools/gemini-cli/WebFetchTool.md +27 -0
  51. beamlab/prompts/tools/gemini-cli/WebSearchTool.md +27 -0
  52. beamlab/prompts/tools/gemini-cli/WriteFileTool.md +35 -0
  53. beamlab/safety/__init__.py +1 -0
  54. beamlab/safety/guardrails.py +171 -0
  55. beamlab/tools/__init__.py +1 -0
  56. beamlab/tools/diff_patch.py +74 -0
  57. beamlab/tools/file_io.py +406 -0
  58. beamlab/tools/memory.py +78 -0
  59. beamlab/tools/registry.py +98 -0
  60. beamlab/tools/search.py +172 -0
  61. beamlab/tools/terminal.py +94 -0
  62. beamlab/tools/test_runner.py +108 -0
  63. beamlab/tools/todo.py +124 -0
  64. beamlab/tools/tool_schemas.py +230 -0
  65. beamlab/tools/web.py +137 -0
  66. beamlab-0.1.0.dist-info/METADATA +80 -0
  67. beamlab-0.1.0.dist-info/RECORD +69 -0
  68. beamlab-0.1.0.dist-info/WHEEL +4 -0
  69. 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
@@ -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