Skip to content

Commit 7dc259d

Browse files
fix: wire DefineTool into live chat execution
Fixes #14
1 parent b01529f commit 7dc259d

4 files changed

Lines changed: 317 additions & 40 deletions

File tree

‎README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -589,11 +589,11 @@ See `plugins/turn_logger.py` for a working example.
589589
| `KYROZEN_BASE_URL` | Custom API base URL | Provider default |
590590
| `KYROZEN_EXECUTION_SURFACE` | Execution surface (`cli` or `web`) | `cli` |
591591
| `KYROZEN_ALLOW_DYNAMIC_TOOLS` | Allow LLM-generated Python tools (`1`/`true`) | CLI: enabled; Web/MCP: disabled |
592-
| `KYROZEN_APPROVAL_MODE` | CLI confirmation mode for high-impact Git actions (`dangerous`/`never`) | `dangerous` |
592+
| `KYROZEN_APPROVAL_MODE` | CLI confirmation mode for high-impact Git actions and dynamic-tool registration (`dangerous`/`never`) | `dangerous` |
593593
| `KYROZEN_WEB_CAPABILITIES` | Web chat capabilities: `readonly`, `workspace`, or `full` | `workspace` |
594594
| `KYROZEN_MCP_CAPABILITIES` | MCP capabilities: `readonly`, `workspace`, or `full` | `workspace` |
595595

596-
The local CLI is intentionally a high-permission agent, similar to Codex or OpenClaw: it can read and write the active workspace, run shell commands, use the network, and operate Git. The Web and MCP surfaces expose the same rich `workspace` profile by default, but keep irreversible `git_reset` and LLM-generated Python tools behind the explicit `full`/`KYROZEN_ALLOW_DYNAMIC_TOOLS=1` opt-in. Authentication and the command safety filter still apply.
596+
The local CLI is intentionally a high-permission agent, similar to Codex or OpenClaw: it can read and write the active workspace, run shell commands, use the network, and operate Git. The Web and MCP surfaces expose the same rich `workspace` profile by default, but keep irreversible `git_reset` and LLM-generated Python tools behind the explicit `full`/`KYROZEN_ALLOW_DYNAMIC_TOOLS=1` opt-in. On the interactive CLI, dynamic registration also follows `KYROZEN_APPROVAL_MODE`; use `never` only for an explicitly automated deployment. Authentication and the command safety filter still apply.
597597

598598
### Config file (`~/.kyrozen_config.json`)
599599

‎dynamic_tools.py‎

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,25 @@
44

55
import ast
66
import builtins
7+
import re
78

89

910
BLOCKED_NAMES = {
1011
"__import__", "eval", "exec", "compile", "open", "input", "globals", "locals", "vars",
1112
"getattr", "setattr", "delattr", "breakpoint", "object", "type",
13+
"issue_capability_token", "grant_capability", "grant_permission", "authorize",
14+
"set_permissions", "allow_dynamic_tools",
1215
}
13-
BLOCKED_ATTRIBUTES = {"system", "popen", "run", "remove", "unlink", "rmdir", "connect", "request"}
16+
BLOCKED_ATTRIBUTES = {
17+
"system", "popen", "run", "remove", "unlink", "rmdir", "connect", "request",
18+
"issue_capability_token", "grant_capability", "grant_permission", "authorize",
19+
"set_permissions",
20+
}
21+
_SECRET_PATTERNS = (
22+
re.compile(r"sk-[A-Za-z0-9_-]{12,}"),
23+
re.compile(r"-----BEGIN [^-]*PRIVATE KEY-----"),
24+
re.compile(r"(?i)(?:api[_-]?key|secret|password|access[_-]?token)\s*=\s*['\"][^'\"]{6,}['\"]"),
25+
)
1426
SAFE_BUILTINS = {
1527
name: getattr(builtins, name) for name in
1628
("abs", "all", "any", "bool", "dict", "enumerate", "filter", "float", "int", "len", "list",
@@ -19,6 +31,9 @@
1931

2032

2133
def validate_tool_source(source: str, function_name: str) -> tuple[bool, str]:
34+
for pattern in _SECRET_PATTERNS:
35+
if pattern.search(source):
36+
return False, "secret-like material is not allowed in generated tools"
2237
try:
2338
tree = ast.parse(source, mode="exec")
2439
except SyntaxError as exc:
@@ -29,8 +44,11 @@ def validate_tool_source(source: str, function_name: str) -> tuple[bool, str]:
2944
for node in ast.walk(tree):
3045
if isinstance(node, (ast.Import, ast.ImportFrom, ast.Global, ast.Nonlocal)):
3146
return False, "imports and global state are not allowed in generated tools"
32-
if isinstance(node, ast.Name) and node.id in BLOCKED_NAMES:
33-
return False, f"blocked builtin: {node.id}"
47+
if isinstance(node, ast.Name):
48+
if node.id in BLOCKED_NAMES:
49+
return False, f"blocked builtin or permission API: {node.id}"
50+
if node.id.startswith("__"):
51+
return False, f"dunder name is not allowed: {node.id}"
3452
if isinstance(node, ast.Attribute):
3553
if node.attr.startswith("__") or node.attr in BLOCKED_ATTRIBUTES:
3654
return False, f"blocked attribute: {node.attr}"

‎main.py‎

Lines changed: 81 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,7 @@ def _clear_tasks_panel() -> None:
280280
)
281281
_APPROVAL_REQUIRED_TOOLS = frozenset({
282282
"git_push", "git_pull", "git_checkout", "git_stash", "git_reset", "git_remote",
283+
"define_tool",
283284
})
284285
_APPROVAL_LOG_PATH = Path("kyrozen_audit.log")
285286

@@ -1319,59 +1320,75 @@ def _get_fix_success_rate() -> float:
13191320
# Feature 3: DefineTool — Dynamic Tool Creation from SKILLs
13201321
# ================================================================
13211322

1323+
def _record_dynamic_tool_event(event_type: str, name: str, *, reason: str = "",
1324+
description: str = "") -> None:
1325+
"""Record a bounded dynamic-tool decision without persisting source code."""
1326+
payload = {"name": str(name)[:80] or "<unknown>"}
1327+
if reason:
1328+
payload["reason"] = str(reason)[:300]
1329+
if description:
1330+
payload["description"] = str(description)[:300]
1331+
try:
1332+
memory_bank.store.append_event(
1333+
event_type, payload, user_id=memory_bank.user_id,
1334+
workspace_id=memory_bank.workspace_id, session_id=memory_bank.session_id,
1335+
)
1336+
except Exception:
1337+
# Audit logging must never turn a safe rejection into a chat failure.
1338+
pass
1339+
1340+
1341+
def _reject_dynamic_tool(name: str, reason: str) -> bool:
1342+
_record_dynamic_tool_event("tool.rejected", name, reason=reason)
1343+
return False
1344+
1345+
13221346
def _register_tool(name: str, code: str, description: str = "") -> bool:
13231347
"""Register a new callable tool dynamically. Returns True on success."""
13241348
if not ALLOW_DYNAMIC_TOOLS:
1325-
return False
1349+
return _reject_dynamic_tool(name, "dynamic tools are disabled by policy")
13261350
if not name or not code:
1327-
return False
1351+
return _reject_dynamic_tool(name, "tool name and source are required")
1352+
if not _execution_capability_token.allows("dynamic"):
1353+
return _reject_dynamic_tool(name, "dynamic capability is not granted or has expired")
13281354
# Validate: name must be a valid identifier
13291355
if not re.match(r"^[a-zA-Z_]\w*$", name):
1330-
return False
1356+
return _reject_dynamic_tool(name, "tool name must be a Python identifier")
13311357

1332-
# Don't overwrite built-in tools
1333-
if name in _BUILTIN_TOOL_NAMES:
1334-
return False
1358+
# Don't overwrite built-in or already registered tools.
1359+
if name in AVAILABLE_TOOLS:
1360+
return _reject_dynamic_tool(name, "tool name is already registered")
13351361

13361362
valid, reason = validate_tool_source(code, name)
13371363
if not valid:
1338-
memory_bank.store.append_event(
1339-
"tool.rejected", {"name": name, "reason": reason},
1340-
user_id=memory_bank.user_id, workspace_id=memory_bank.workspace_id,
1341-
session_id=memory_bank.session_id,
1342-
)
1343-
return False
1364+
return _reject_dynamic_tool(name, reason)
13441365

13451366
# Compile the tool function in a restricted global namespace.
13461367
try:
13471368
local_ns: dict[str, Any] = {}
13481369
exec(code, {"__builtins__": SAFE_BUILTINS}, local_ns)
13491370
fn = local_ns.get(name)
13501371
if fn is None or not callable(fn):
1351-
# Try to find any top-level function
1352-
for v in local_ns.values():
1353-
if callable(v) and hasattr(v, "__name__"):
1354-
fn = v
1355-
break
1356-
if fn is None:
1357-
return False
1358-
except Exception:
1359-
return False
1372+
return _reject_dynamic_tool(name, "source did not create the requested callable")
1373+
except Exception as exc:
1374+
return _reject_dynamic_tool(name, f"tool compilation failed: {type(exc).__name__}")
13601375

13611376
# Apply description
1362-
if description and hasattr(fn, "__doc__"):
1363-
pass # uses its own docstring
1364-
elif description:
1377+
if description and not getattr(fn, "__doc__", None):
13651378
fn.__doc__ = description
13661379

1380+
if not _confirm_tool_action("define_tool", name):
1381+
return _reject_dynamic_tool(name, "dynamic-tool approval was denied")
1382+
13671383
# Register
13681384
AVAILABLE_TOOLS[name] = fn
13691385
# Rebuild tools list for system prompt
13701386
global TOOLS_LIST
13711387
TOOLS_LIST = _build_tools_list()
13721388

1373-
# Log the new tool
1374-
memory_bank.add_log(f"TOOL_CREATED: {name} — {description}")
1389+
# Log the decision and inventory change, never the generated source.
1390+
_record_dynamic_tool_event("tool.registered", name, description=description)
1391+
memory_bank.add_log(f"TOOL_CREATED: {name} — {description[:300]}")
13751392
return True
13761393

13771394

@@ -1385,22 +1402,21 @@ def tool_name(args: str) -> str:
13851402
...
13861403
```
13871404
"""
1388-
if not ALLOW_DYNAMIC_TOOLS:
1405+
if not re.search(r"DefineTool\s*:", text, re.IGNORECASE):
13891406
return False
1390-
13911407
pattern = r"DefineTool:\s*```(?:python)?\s*([\s\S]*?)\s*```"
13921408
match = re.search(pattern, text)
13931409
if not match:
1394-
return False
1410+
return _reject_dynamic_tool("<unknown>", "malformed DefineTool block")
13951411

13961412
code = match.group(1).strip()
13971413
if not code:
1398-
return False
1414+
return _reject_dynamic_tool("<unknown>", "DefineTool source is empty")
13991415

14001416
# Extract function name and description
14011417
name_match = re.search(r"def\s+(\w+)\s*\(", code)
14021418
if not name_match:
1403-
return False
1419+
return _reject_dynamic_tool("<unknown>", "DefineTool source has no function definition")
14041420
name = name_match.group(1)
14051421

14061422
desc_match = re.search(r'"""([^"]*)"""', code) or re.search(r"'''([^']*)'''", code)
@@ -1752,6 +1768,16 @@ def _system_prompt(tools_list: str) -> str:
17521768
"## Current working directory\n"
17531769
"You are currently inside the project root directory of the repository the user is working in. Relative paths (like \"README.md\" or \"main.py\") will be resolved correctly. You can use `read_file`, `write_file`, `list_dir`, `find_files`, `run_cmd`, etc. **without needing the user to provide a path**. Do **not** ask the user to supply a local path or a remote URL unless you intend to use the `analyze_remote_repo` tool to clone an external repository."
17541770
)
1771+
dynamic_tool_instructions = (
1772+
"## Dynamic tools\n"
1773+
"Dynamic tools are enabled only when the active surface grants the `dynamic` capability and the approval policy allows registration. "
1774+
"When a missing pure helper is genuinely needed, you may output exactly one DefineTool block; never include imports, filesystem/process/network access, secrets, permission changes, or capability grants. "
1775+
"The runtime validates and registers it, refreshes the tool inventory, and then you may call it by its exact name:\n"
1776+
"DefineTool:\n```python\ndef tool_name(args: str) -> str:\n return args.strip()\n```\n"
1777+
) if ALLOW_DYNAMIC_TOOLS else (
1778+
"## Dynamic tools\n"
1779+
"Dynamic tool creation is disabled for this execution surface. Do not emit DefineTool blocks.\n"
1780+
)
17551781
# Build system prompt via safe concatenation (no f‑string to avoid format‑spec collisions)
17561782
return (
17571783
"You are Kyrozen, an intelligent, self-learning AI assistant with file access, "
@@ -1836,6 +1862,7 @@ def _system_prompt(tools_list: str) -> str:
18361862
"6. **SUMMARISE**: When all tasks complete, produce a concise summary of what was done.\n"
18371863
"CRITICAL: Never skip tasks, never stop early, never mark tasks done without executing them.\n"
18381864
"If you get stuck on a step, try an alternative approach — do NOT abandon the task.\n"
1865+
+ dynamic_tool_instructions + "\n"
18391866
+ cwd_note
18401867
)
18411868

@@ -2881,6 +2908,8 @@ def _clean_final_response(text: str) -> str:
28812908
return ""
28822909
# Remove fenced protocol blocks first. The model's JSON may contain
28832910
# braces and newlines, so a line-based JSON parser would be less reliable.
2911+
cleaned = re.sub(r"DefineTool:\s*```(?:python)?\s*[\s\S]*?```", "", cleaned,
2912+
flags=re.IGNORECASE)
28842913
cleaned = re.sub(r"Action:\s*```(?:json)?\s*[\s\S]*?```", "", cleaned,
28852914
flags=re.IGNORECASE)
28862915
cleaned = re.sub(r"TaskList:\s*```(?:json)?\s*[\s\S]*?```", "", cleaned,
@@ -2933,7 +2962,12 @@ def _parse_model_response(text: str) -> dict[str, Any]:
29332962

29342963
def _observe_model_response(text: str) -> dict[str, Any]:
29352964
"""Parse a response once and merge its durable task signals once."""
2965+
define_tool_present = bool(re.search(r"^[ \t]*DefineTool\s*:", str(text or ""),
2966+
re.IGNORECASE | re.MULTILINE))
2967+
define_tool_registered = _attempt_define_tool(text) if define_tool_present else False
29362968
parsed = _parse_model_response(text)
2969+
parsed["define_tool_present"] = define_tool_present
2970+
parsed["define_tool_registered"] = define_tool_registered
29372971
if parsed["raw"]:
29382972
tasks.from_llm_block(parsed["raw"])
29392973
tasks.mark_done_from_text(parsed["raw"])
@@ -3142,6 +3176,11 @@ def _chat_turn(user_input: str, clear_tasks: bool = False, profile: str | None =
31423176
# model context; use the parsed clean field for anything user-facing.
31433177
response_meta = _observe_model_response(response_text)
31443178
tool_calls = response_meta["tool_calls"]
3179+
if response_meta["define_tool_registered"]:
3180+
messages.append({
3181+
"role": "system",
3182+
"content": "Refreshed tool inventory after DefineTool registration:\n" + TOOLS_LIST,
3183+
})
31453184

31463185
# ---- Unknown action detection (all complexity levels) ----
31473186
_unknown_retries = 0
@@ -3210,15 +3249,17 @@ def _chat_turn(user_input: str, clear_tasks: bool = False, profile: str | None =
32103249

32113250
# ---- Missing action recovery (up to 3 attempts) ----
32123251
if not tool_calls:
3213-
if _requires_tool_action(user_input) or _llm_has_plan or _llm_has_tasklist:
3252+
if (_requires_tool_action(user_input) or _llm_has_plan or _llm_has_tasklist
3253+
or response_meta["define_tool_registered"]):
32143254
action_retries = 0
32153255
while not tool_calls and action_retries < 3:
32163256
action_retries += 1
32173257
if action_retries == 1:
32183258
reminder = (
3219-
"System: You output a Plan but no Action block. "
3259+
"System: You output a protocol block but no Action block. "
32203260
"You **must** now output a JSON Action block to perform the work. "
3221-
"Do not repeat the Plan — output ONLY: Thought + Action JSON."
3261+
"If a new tool was registered, use its exact name from the refreshed tool inventory. "
3262+
"Do not repeat the Plan or DefineTool block — output only the next Action."
32223263
)
32233264
elif action_retries == 2:
32243265
reminder = (
@@ -3240,6 +3281,11 @@ def _chat_turn(user_input: str, clear_tasks: bool = False, profile: str | None =
32403281
continue
32413282
response_meta = _observe_model_response(response_text)
32423283
tool_calls = response_meta["tool_calls"]
3284+
if response_meta["define_tool_registered"]:
3285+
messages.append({
3286+
"role": "system",
3287+
"content": "Refreshed tool inventory after DefineTool registration:\n" + TOOLS_LIST,
3288+
})
32433289
if tool_calls:
32443290
_llm_has_plan = response_meta["has_plan"]
32453291
_llm_has_tasklist = response_meta["has_tasklist"]
@@ -3446,7 +3492,7 @@ def _chat_turn(user_input: str, clear_tasks: bool = False, profile: str | None =
34463492
if not next_tool_calls:
34473493
# If all tasks are already done (or none were set), accept this as final answer
34483494
all_done = not tasks.tasks or all(is_terminal(t) for t in tasks.tasks)
3449-
if all_done:
3495+
if all_done and not step_meta["define_tool_registered"]:
34503496
final_answer = (step_meta["clean"] or _deterministic_tool_summary(tool_records))
34513497
break
34523498

0 commit comments

Comments
 (0)