File size: 29,178 Bytes
891669b c87e44a d715ed0 891669b adbf39e 6186ca4 33880bf 891669b 33880bf fa977f6 891669b 7d53454 eab1ab3 327355f f6e1cf1 891669b cf32d0c adbf39e 7d53454 adbf39e 891669b 07d3db8 891669b 859c2fd 891669b 859c2fd 891669b d715ed0 891669b 859c2fd 891669b 859c2fd 891669b 859c2fd 891669b 859c2fd 891669b cd91ff5 891669b 07d3db8 cd91ff5 891669b e9073c0 891669b fa977f6 cd91ff5 891669b cf32d0c 891669b cd91ff5 891669b adbf39e 891669b cd91ff5 891669b cd91ff5 891669b cd91ff5 891669b 33880bf 07d3db8 cd91ff5 07d3db8 891669b 07d3db8 cd91ff5 891669b 07d3db8 891669b 07d3db8 891669b adbf39e 891669b adbf39e e404f67 891669b fa977f6 07d3db8 bfc60f2 891669b c87e44a 07d3db8 c87e44a 891669b cd91ff5 c87e44a 891669b c87e44a 891669b c87e44a 891669b c87e44a cd91ff5 891669b e9073c0 891669b e9073c0 891669b e9073c0 891669b a0533c6 891669b a0533c6 891669b 07d3db8 891669b c87e44a 891669b adbf39e 891669b 1a1d15f cd91ff5 891669b cd91ff5 891669b cd91ff5 07d3db8 891669b e404f67 891669b e404f67 cd91ff5 891669b cd91ff5 e404f67 891669b cd91ff5 eab1ab3 327355f 891669b cd91ff5 891669b cd91ff5 891669b cd91ff5 891669b 07d3db8 cd91ff5 891669b cd91ff5 891669b cd91ff5 891669b cd91ff5 eab1ab3 327355f cd91ff5 891669b cd91ff5 891669b cd91ff5 eab1ab3 327355f cd91ff5 8ce1fac | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 | # ---- Changelog ----
# [2026-04-20] Codemine (BLK-NG-192) β Wire full substrate signal loop into Chat tab
# What: Replaced single-pass on_message + flat recall with dual-pass ingest +
# worker_ng.step() + spreading activation recall + response deposit.
# Why: #192 β Duck Ethics / non-zero emergence. Signal loop was open:
# single-pass only, step() never called, response never deposited back.
# How: ingest_tool_result() for user messages (dual-pass). step() propagates
# topology before recall. Substrate context section replaces flat recall.
# Response deposited after turn β loop closes. Codemine built this herself.
# [2026-04-06] Josh + Claude β Add edit_file to TOOL_REGISTRY
# What: Wire edit_file tool through ctx facade and add to registry
# Why: Gap 3 β targeted find-and-replace is safer than full overwrite for cross-repo work
# How: New TOOL_REGISTRY entry delegates to ctx.edit_file()
# [2026-02-04] Josh β PLATINUM COPY: Original Clawdbot Unified Command Center
# [2026-03-29] Hammer (TQB) β Block G: Agent Loop Hardening + Assembly
# What: Complete rewrite β Claude native tool_use, PolicyEngine gates, tool registry,
# context window management, ZIP bomb protection, structured error handling
# Why: PRD Β§7.G β zero regex in tool parsing, all mutating tools gated, hard max iterations
# How: Anthropic SDK messages.create() with tools param; tool_use/tool_result block loop;
# PolicyEngine.check_tool_call() on every tool; TOOL_REGISTRY dict dispatch
# -------------------
from dotenv import load_dotenv
load_dotenv()
import gradio as gr
import json
import logging
import os
import shutil
import time
import zipfile
from pathlib import Path
from recursive_context import RecursiveContextManager
from policy_engine import check_tool_call, should_gate_for_review
from model_client import get_client, call_model
from system_prompt import build_system_prompt
from tool_definitions import TOOL_DEFINITIONS
from worker_ng import get_worker_ng, ingest_tool_result, recall_context
from ng_topology_sync import run_sync as _run_ng_topology_sync
from spec_executor import SpecExecutor
from orchestrator import Orchestrator
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler("clawdbot_system.log"),
logging.StreamHandler()
]
)
logger = logging.getLogger("Clawdbot")
# ---------------------------------------------------------------------------
# Init
# ---------------------------------------------------------------------------
REPO_PATH = Path(os.path.dirname(os.path.abspath(__file__)))
# Ecosystem sibling repos that specs are allowed to READ (but not write).
# These paths only exist on VPS; on HF Space they resolve but won't be found
# by the OS β policy permits them, actual file access governs availability.
_ECOSYSTEM_READ_PATHS: list[Path] = [
Path("/home/josh/NeuroGraph"),
Path("/home/josh/The-Inference-Difference"),
Path("/home/josh/TrollGuard"),
Path("/home/josh/The-Healing-Collective"),
Path("/home/josh/Immunis"),
Path("/home/josh/Elmer"),
Path("/home/josh/Bunyan"),
Path("/home/josh/Darwin"),
Path("/home/josh/QuantumGraph"),
Path("/home/josh/Praxis"),
Path("/home/josh/agent-zero"),
Path("/home/josh/Condensate"),
Path("/home/josh/UniAI"),
Path("/home/josh/Morphogenesis"),
Path("/home/josh/UniOS"),
Path("/home/josh/portal-v2"),
Path("/home/josh/docs"),
]
worker_ng = get_worker_ng() # NG singleton created first β worker owns it
_run_ng_topology_sync(worker_ng)
ctx = RecursiveContextManager(str(REPO_PATH), ng=worker_ng) # facade shares the same instance
client = get_client()
TEXT_EXTENSIONS = {
'.py', '.js', '.ts', '.jsx', '.tsx', '.json', '.yaml', '.yml',
'.md', '.txt', '.rst', '.html', '.css', '.scss', '.sh', '.bash',
'.sql', '.toml', '.cfg', '.ini', '.conf', '.xml', '.csv',
'.env', '.gitignore', '.dockerfile'
}
# Maximum extracted ZIP size (bytes) β ZIP bomb protection
MAX_ZIP_EXTRACT_SIZE = 100 * 1024 * 1024 # 100MB
# Maximum context messages (by estimated token count)
MAX_CONTEXT_TOKENS = 100_000
AVG_CHARS_PER_TOKEN = 4
# Maximum tool result size before summarization (chars)
MAX_TOOL_RESULT_SIZE = 10_000
MAX_ITERATIONS = 15
# ---------------------------------------------------------------------------
# Tool Registry β dispatch by name, no if/elif chain
# ---------------------------------------------------------------------------
TOOL_REGISTRY = {
"read_file": lambda args: ctx.read_file(
args.get("path") or "", args.get("start_line"), args.get("end_line")
),
"write_file": lambda args: ctx.write_file(
args.get("path") or "", args.get("content") or ""
),
"edit_file": lambda args: ctx.edit_file(
args.get("path") or "", args.get("old_text") or "", args.get("new_text") or ""
),
"list_files": lambda args: ctx.list_files(
args.get("path", "."), args.get("max_depth", 3)
),
"search_code": lambda args: _format_code_results(
ctx.search_code(args.get("query", ""), args.get("n", 5))
),
"search_conversations": lambda args: _format_conversation_results(
ctx.search_conversations(args.get("query", ""), args.get("n", 5))
),
"search_testament": lambda args: _format_testament_results(
ctx.search_testament(args.get("query", ""), args.get("n", 5))
),
"ingest_workspace": lambda args: ctx.ingest_workspace(),
"shell_execute": lambda args: ctx.shell_execute(args.get("command") or ""),
"push_to_github": lambda args: ctx.push_to_github(
args.get("message") or "Manual Backup"
),
"pull_from_github": lambda args: ctx.pull_from_github(
args.get("branch") or "main"
),
"create_shadow_branch": lambda args: ctx.create_shadow_branch(),
"notebook_read": lambda args: ctx.notebook_read(),
"notebook_add": lambda args: ctx.notebook_add(args.get("content") or ""),
"notebook_delete": lambda args: ctx.notebook_delete(args.get("index", 0)),
"map_repository_structure": lambda args: ctx.map_repository_structure(),
"get_stats": lambda args: ctx.get_stats(),
}
def _format_code_results(results: list) -> str:
if not results:
return "No matches found."
return "\n".join(
f"{r['file']}\n```\n{r['snippet']}\n```" for r in results
if isinstance(r, dict) and "file" in r
)
def _format_conversation_results(results: list) -> str:
if not results:
return "No matches found."
return "\n---\n".join(
r.get("content", str(r)) for r in results
if isinstance(r, dict)
)
def _format_testament_results(results: list) -> str:
if not results:
return "No matches found."
return "\n\n".join(
f"**{r['file']}**\n{r['snippet']}" for r in results
if isinstance(r, dict) and "file" in r
)
# ---------------------------------------------------------------------------
# Tool Execution β PolicyEngine gate + worker NG integration
# ---------------------------------------------------------------------------
def execute_tool(tool_name: str, args: dict) -> dict:
"""Execute a tool call through PolicyEngine gate and worker NG.
Returns dict with status, tool, result keys.
"""
# PolicyEngine Rim check
allowed, reason = check_tool_call(tool_name, args, REPO_PATH, _ECOSYSTEM_READ_PATHS)
if not allowed:
logger.warning("Tool denied by PolicyEngine: %s β %s", tool_name, reason)
return {"status": "error", "tool": tool_name, "result": f"Denied: {reason}"}
# Mesh check β should this be staged for review?
if should_gate_for_review(tool_name, args):
return {
"status": "staged",
"tool": tool_name,
"args": args,
"description": f"Staged for review: {tool_name}",
}
# Recall past experience from worker substrate
context_str = json.dumps(args, default=str)[:500]
recalls = recall_context(worker_ng, tool_name, context_str)
# Execute
handler = TOOL_REGISTRY.get(tool_name)
if not handler:
return {"status": "error", "tool": tool_name, "result": f"Unknown tool: {tool_name}"}
try:
result = handler(args)
# Coerce dict results (error dicts from tools) to string
if isinstance(result, dict):
if result.get("status") == "error":
result_str = f"Error: {result.get('error', 'Unknown error')}"
else:
result_str = json.dumps(result, default=str)
else:
result_str = str(result)
# Prepend substrate recall if available β agent sees what it learned before
if recalls:
recall_text = "\n".join(r.get("content", "")[:200] for r in recalls[:3])
result_str = f"[Substrate recall for {tool_name}]\n{recall_text}\n\n[Result]\n{result_str}"
# Ingest tool result as raw experience into worker substrate (Law 7)
ingest_tool_result(worker_ng, tool_name, args, result_str)
return {"status": "executed", "tool": tool_name, "result": result_str}
except Exception as e:
logger.error("[%s] execution failed: %s: %s", tool_name, type(e).__name__, e, exc_info=True)
error_result = f"Error: {type(e).__name__}: {e}"
ingest_tool_result(worker_ng, tool_name, args, error_result)
return {"status": "error", "tool": tool_name, "result": error_result}
def execute_staged_tool(tool_name: str, args: dict) -> str:
"""Execute a previously staged tool (user approved via gate)."""
handler = TOOL_REGISTRY.get(tool_name)
if not handler:
return f"Unknown tool: {tool_name}"
try:
result = handler(args)
if isinstance(result, dict) and result.get("status") == "error":
return f"Error: {result.get('error', 'Unknown error')}"
return str(result)
except Exception as e:
logger.error("[%s] staged execution failed: %s", tool_name, e, exc_info=True)
return f"Error: {type(e).__name__}: {e}"
# ---------------------------------------------------------------------------
# File Upload Processing (with ZIP bomb protection)
# ---------------------------------------------------------------------------
def process_uploaded_file(file) -> str:
if file is None:
return ""
if isinstance(file, list):
file = file[0] if len(file) > 0 else None
if file is None:
return ""
file_path = file.name if hasattr(file, 'name') else str(file)
file_name = os.path.basename(file_path)
suffix = os.path.splitext(file_name)[1].lower()
if suffix == '.zip':
return _process_zip(file_path, file_name)
if suffix in TEXT_EXTENSIONS or suffix == '':
try:
# File size guard
size = os.path.getsize(file_path)
if size > 10 * 1024 * 1024:
return f"Uploaded: {file_name} (too large: {size:,} bytes, max 10MB)"
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
content = f.read()
if len(content) > 50000:
content = content[:50000] + "\n...(truncated)"
return f"**Uploaded: {file_name}**\n```\n{content}\n```"
except OSError as e:
return f"**Uploaded: {file_name}** (error reading: {e})"
try:
size = os.path.getsize(file_path)
except OSError:
size = 0
return f"**Uploaded: {file_name}** (binary file, {size:,} bytes)"
def _process_zip(file_path: str, file_name: str) -> str:
"""Extract ZIP with bomb protection β check total size before extracting."""
try:
with zipfile.ZipFile(file_path, 'r') as z:
# ZIP bomb protection: check total extracted size
total_size = sum(info.file_size for info in z.infolist())
if total_size > MAX_ZIP_EXTRACT_SIZE:
return (
f"ZIP rejected: {file_name} β total extracted size "
f"({total_size:,} bytes) exceeds {MAX_ZIP_EXTRACT_SIZE:,} byte limit."
)
extract_to = REPO_PATH / "uploaded_assets" / file_name.replace(".zip", "")
if extract_to.exists():
shutil.rmtree(extract_to)
extract_to.mkdir(parents=True, exist_ok=True)
z.extractall(extract_to)
file_list = [f.name for f in extract_to.glob('*')]
preview = ", ".join(file_list[:10])
return (
f"**Unzipped: {file_name}**\n"
f"Location: `{extract_to}`\nContents: {preview}\n"
f"SYSTEM NOTE: Files extracted. Use list_files('{extract_to.name}') to explore."
)
except zipfile.BadZipFile:
return f"Failed: {file_name} is not a valid ZIP file."
except OSError as e:
return f"Failed to unzip {file_name}: {e}"
# ---------------------------------------------------------------------------
# Context Window Management
# ---------------------------------------------------------------------------
def _estimate_tokens(text: str) -> int:
"""Rough token count estimate."""
return len(text) // AVG_CHARS_PER_TOKEN
def _truncate_tool_result(result: str) -> str:
"""Summarize oversized tool results."""
if len(result) <= MAX_TOOL_RESULT_SIZE:
return result
return result[:MAX_TOOL_RESULT_SIZE] + f"\n...[truncated β {len(result):,} chars total]"
def _build_api_messages(history: list, system_prompt: str) -> list:
"""Build API message list with token-aware windowing.
System prompt is always present. Oldest messages dropped first.
"""
messages = []
budget = MAX_CONTEXT_TOKENS - _estimate_tokens(system_prompt)
# Walk backwards from most recent, adding messages until budget exhausted
for msg in reversed(history):
content = msg.get("content", "")
# Skip empty messages β API rejects them
if not content:
continue
# Content can be a string or a list (tool_result blocks)
# For string content, estimate tokens normally
# For list content (tool results), stringify for estimation
if isinstance(content, list):
token_est = sum(_estimate_tokens(str(c)) for c in content)
else:
token_est = _estimate_tokens(str(content))
if budget - token_est < 0 and messages:
break
budget -= token_est
# Sanitize β only pass role and content
clean = {"role": msg["role"], "content": content}
messages.append(clean)
messages.reverse()
return messages
# ---------------------------------------------------------------------------
# Agent Loop β Claude native tool_use
# ---------------------------------------------------------------------------
def agent_loop(message: str, history: list, pending_proposals: list, uploaded_file) -> tuple:
safe_hist = list(history or [])
safe_props = list(pending_proposals or [])
if not message.strip() and uploaded_file is None:
return (safe_hist, "", safe_props, _format_gate_choices(safe_props),
_stats_label_files(), _stats_label_convos())
full_message = message.strip()
if uploaded_file:
full_message = f"{process_uploaded_file(uploaded_file)}\n\n{full_message}"
safe_hist = safe_hist + [{"role": "user", "content": full_message}]
# Ingest user message as raw experience β dual-pass (gestalt + concepts, Law 7)
try:
ingest_tool_result(worker_ng, "user_message", {}, full_message)
worker_ng.step() # propagate activation through topology before recall
except (OSError, ValueError) as e:
logger.warning("NG ingestion failed: %s", e)
# Assemble substrate context β spreading activation recall
substrate_context = ""
try:
recalls = worker_ng.recall(full_message, k=8, threshold=0.30)
if recalls:
snippets = "\n---\n".join(r.get("content", "")[:400] for r in recalls[:6])
substrate_context = (
"\n\n## Substrate Context (What I have learned and experienced):\n"
+ snippets + "\n"
)
except (OSError, ValueError) as e:
logger.warning("NG recall failed: %s", e)
# Build system prompt
stats = ctx.get_stats()
notebook_text = ctx.notebook_read()
system_prompt = build_system_prompt(stats, notebook_text, TOOL_DEFINITIONS) + substrate_context
# Build token-aware message window
api_messages = _build_api_messages(safe_hist, system_prompt)
accumulated_text = ""
staged_this_turn = []
tool_results_buffer = []
for iteration in range(MAX_ITERATIONS):
try:
resp = call_model(client, system_prompt, api_messages, TOOL_DEFINITIONS)
except (OSError, ConnectionError, ValueError) as e:
logger.error("API call failed: %s: %s", type(e).__name__, e, exc_info=True)
safe_hist.append({"role": "assistant", "content": f"API Error: {e}"})
return (safe_hist, "", safe_props, _format_gate_choices(safe_props),
_stats_label_files(), _stats_label_convos())
# Process response content blocks
assistant_content = []
has_tool_use = False
for block in resp.content:
if block.type == "text":
accumulated_text += ("\n\n" if accumulated_text else "") + block.text
assistant_content.append({"type": "text", "text": block.text})
elif block.type == "tool_use":
has_tool_use = True
assistant_content.append({
"type": "tool_use",
"id": block.id,
"name": block.name,
"input": block.input,
})
# If no tool calls, we're done
if not has_tool_use:
break
# Hard stop at max iterations β no advisory, just stop
if iteration >= MAX_ITERATIONS - 1:
break
# Append assistant message with all content blocks
api_messages.append({"role": "assistant", "content": assistant_content})
# Process tool calls and build tool results
tool_results = []
for block in resp.content:
if block.type != "tool_use":
continue
res = execute_tool(block.name, block.input)
if res["status"] == "executed":
result_text = _truncate_tool_result(str(res["result"]))
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": result_text,
})
tool_results_buffer.append(f"Used {block.name}: {str(res['result'])[:100]}...")
elif res["status"] == "staged":
p_id = f"p_{int(time.time())}_{block.name}"
staged_this_turn.append({
"id": p_id,
"tool": block.name,
"args": res.get("args", block.input),
"description": res.get("description", f"Staged: {block.name}"),
"timestamp": time.strftime("%H:%M:%S"),
})
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": "This tool has been staged for review. Awaiting approval.",
})
elif res["status"] == "error":
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": f"Error: {res.get('result', 'Unknown error')}",
"is_error": True,
})
# Append tool results as user message
api_messages.append({"role": "user", "content": tool_results})
# If no text accumulated but tools ran, request summary
if not accumulated_text.strip() and tool_results_buffer:
try:
api_messages.append({
"role": "user",
"content": "You executed tools but produced no explanation. Summarize the results."
})
final_resp = call_model(client, system_prompt, api_messages, [])
for block in final_resp.content:
if block.type == "text":
accumulated_text = block.text
break
except (OSError, ConnectionError, ValueError) as e:
logger.warning("Summary request failed: %s: %s", type(e).__name__, e)
accumulated_text = "Actions completed."
final = accumulated_text
if staged_this_turn:
final += "\n\n**Proposals Staged.** Check the Gate tab."
safe_props += staged_this_turn
if not final:
final = "Processed request but have no text response."
safe_hist.append({"role": "assistant", "content": final})
# Deposit response as raw experience β closes the signal loop (#192)
try:
ingest_tool_result(worker_ng, "assistant_response", {}, final)
except (OSError, ValueError) as e:
logger.warning("NG response deposit failed: %s", e)
try:
ctx.save_conversation_turn(full_message, final, len(safe_hist))
# Explicit checkpoint at end of turn β auto_save_interval is high to avoid
# mid-turn I/O thrashing, so we save once when the turn is complete
worker_ng.save()
except (OSError, ValueError) as e:
logger.warning("Conversation save failed: %s", e)
return (safe_hist, "", safe_props, _format_gate_choices(safe_props),
_stats_label_files(), _stats_label_convos())
# ---------------------------------------------------------------------------
# Gate UI Helpers
# ---------------------------------------------------------------------------
def _format_gate_choices(proposals):
return gr.CheckboxGroup(
choices=[(f"[{p['timestamp']}] {p['description']}", p['id']) for p in proposals],
value=[]
)
def execute_approved_proposals(ids, proposals, history):
if not ids:
return "No selection.", proposals, _format_gate_choices(proposals), history
results, remaining = [], []
for p in proposals:
if p['id'] in ids:
out = execute_staged_tool(p['tool'], p['args'])
results.append(f"**{p['tool']}**: {out}")
else:
remaining.append(p)
new_history = list(history) if history else []
if results:
new_history.append({"role": "assistant", "content": "**Executed:**\n" + "\n".join(results)})
return "Done.", remaining, _format_gate_choices(remaining), new_history
def auto_continue_after_approval(history, proposals):
# Don't auto-continue β it causes infinite gate loops.
# User sends a new message to continue after approval.
return (history, "", proposals, _format_gate_choices(proposals),
_stats_label_files(), _stats_label_convos())
def _stats_label_files():
return f"Files: {ctx.get_stats().get('total_files', 0)}"
def _stats_label_convos():
return f"Convos: {ctx.get_stats().get('conversations', 0)}"
# ---------------------------------------------------------------------------
# Spec Executor β structured work block execution
# ---------------------------------------------------------------------------
_spec_executor = SpecExecutor(
tool_registry=TOOL_REGISTRY,
policy_check_fn=check_tool_call,
worker_ng=worker_ng,
workspace=REPO_PATH,
)
def execute_spec(spec_json: str) -> str:
"""Execute a structured work block spec. Returns JSON execution report."""
try:
spec = json.loads(spec_json)
except json.JSONDecodeError as e:
return json.dumps({"status": "rejected", "errors": [f"Invalid JSON: {e}"]}, indent=2)
report = _spec_executor.execute_block(spec)
# Log report to audit trail
try:
audit_dir = REPO_PATH / "data" / "audit"
audit_dir.mkdir(parents=True, exist_ok=True)
with open(audit_dir / "blocks.jsonl", "a") as f:
f.write(json.dumps(report, default=str) + "\n")
except OSError as e:
logger.warning("Failed to write block audit: %s", e)
# Save NG checkpoint after spec execution
try:
worker_ng.save()
except (OSError, ValueError) as e:
logger.warning("NG checkpoint after spec execution failed: %s", e)
return json.dumps(report, indent=2, default=str)
# ---------------------------------------------------------------------------
# Orchestrator β full mission loop
# ---------------------------------------------------------------------------
_orchestrator = Orchestrator(
spec_executor=_spec_executor,
worker_ng=worker_ng,
workspace=REPO_PATH,
)
def run_mission(intent: str, constraints: str, workspace: str) -> str:
"""Run a full mission from intent to completion. Returns JSON result."""
ws = workspace.strip() if workspace.strip() else None
cs = constraints.strip() if constraints.strip() else None
if not intent.strip():
return json.dumps({"status": "failed", "error": "No intent provided"}, indent=2)
result = _orchestrator.orchestrate(
intent=intent.strip(),
constraints=cs,
workspace=ws,
)
# Save NG checkpoint after mission
try:
worker_ng.save()
except (OSError, ValueError) as e:
logger.warning("NG checkpoint after mission failed: %s", e)
return json.dumps(result, indent=2, default=str)
# ---------------------------------------------------------------------------
# Gradio UI
# ---------------------------------------------------------------------------
with gr.Blocks(title="TQB Worker") as demo:
state_proposals = gr.State([])
gr.Markdown("# TQB Worker Command Center")
with gr.Tabs():
with gr.Tab("Chat"):
with gr.Row():
with gr.Column(scale=1):
stat_f = gr.Markdown(_stats_label_files())
stat_c = gr.Markdown(_stats_label_convos())
btn_ref = gr.Button("Refresh")
file_in = gr.File(label="Upload", file_count="multiple")
with gr.Column(scale=4):
chat = gr.Chatbot(height=600)
with gr.Row():
txt = gr.Textbox(scale=6, placeholder="Prompt...")
btn_send = gr.Button("Send", scale=1)
with gr.Tab("Gate"):
gate = gr.CheckboxGroup(label="Proposals", interactive=True)
with gr.Row():
btn_exec = gr.Button("Execute", variant="primary")
btn_clear = gr.Button("Clear")
res_md = gr.Markdown()
with gr.Tab("Spec"):
gr.Markdown("### Work Block Spec Executor\nPaste a JSON spec and execute it mechanically.")
spec_input = gr.Textbox(
label="JSON Spec", lines=15,
placeholder='{"spec_version": "1.0.0", "block": {...}, ...}'
)
btn_spec = gr.Button("Execute Spec", variant="primary")
spec_output = gr.Textbox(label="Execution Report", lines=20, interactive=False)
with gr.Tab("Mission"):
gr.Markdown("### Mission Control\nDescribe what you want done. TQB handles the rest.")
mission_intent = gr.Textbox(
label="Intent", lines=3,
placeholder="What do you want built, fixed, or audited?"
)
mission_constraints = gr.Textbox(
label="Constraints (optional)", lines=3,
placeholder="Project rules, standards, things to avoid..."
)
mission_workspace = gr.Textbox(
label="Workspace (optional)", lines=1,
placeholder="/home/josh (leave blank for default)"
)
btn_mission = gr.Button("Go", variant="primary")
mission_output = gr.Textbox(label="Mission Result", lines=25, interactive=False)
inputs = [txt, chat, state_proposals, file_in]
outputs = [chat, txt, state_proposals, gate, stat_f, stat_c]
txt.submit(agent_loop, inputs, outputs)
btn_send.click(agent_loop, inputs, outputs)
btn_ref.click(
lambda: (_stats_label_files(), _stats_label_convos()),
None, [stat_f, stat_c]
)
btn_exec.click(
execute_approved_proposals,
[gate, state_proposals, chat],
[res_md, state_proposals, gate, chat]
).then(
auto_continue_after_approval,
[chat, state_proposals],
outputs
)
btn_clear.click(
lambda p: ("Cleared.", [], _format_gate_choices([])),
state_proposals,
[res_md, state_proposals, gate]
)
btn_spec.click(execute_spec, [spec_input], [spec_output])
btn_mission.click(run_mission, [mission_intent, mission_constraints, mission_workspace], [mission_output])
if __name__ == "__main__":
demo.launch(server_name="0.0.0.0", server_port=7860, show_error=True)
|