mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-30 00:22:10 -04:00
refactor(tools): register update_plan tool and support dynamic execution (#4069)
* refactor(tools): register update_plan tool and support dynamic execution * refactor: move interaction tools to registry and fix tuple unpacking error * docs: add HACK comment for circular dependency workaround Signed-off-by: dewanggaabdullah <255674162+dewanggaabdullah@users.noreply.github.com> * refactor(tools): use docstring for better code style Signed-off-by: dewanggaabdullah <255674162+dewanggaabdullah@users.noreply.github.com> * fix(tools & file): restore file tool_registry & unknown tool fallback and fix dynamic handlers unpacking Signed-off-by: dewanggaabdullah <255674162+dewanggaabdullah@users.noreply.github.com> --------- Signed-off-by: dewanggaabdullah <255674162+dewanggaabdullah@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
2dfc83ee22
commit
6d429a49b9
@@ -22,6 +22,7 @@ from .subprocess_tools import BashTool, PythonTool
|
|||||||
from .web_tools import WebSearchTool, WebFetchTool
|
from .web_tools import WebSearchTool, WebFetchTool
|
||||||
from .filesystem_tools import ReadFileTool, WriteFileTool, EditFileTool, LsTool, GlobTool, GrepTool, GetWorkspaceTool
|
from .filesystem_tools import ReadFileTool, WriteFileTool, EditFileTool, LsTool, GlobTool, GrepTool, GetWorkspaceTool
|
||||||
from .document_tools import CreateDocumentTool, UpdateDocumentTool, EditDocumentTool, SuggestDocumentTool, ManageDocumentTool
|
from .document_tools import CreateDocumentTool, UpdateDocumentTool, EditDocumentTool, SuggestDocumentTool, ManageDocumentTool
|
||||||
|
from .interaction_tools import AskUserTool, UpdatePlanTool
|
||||||
from .model_interaction_tools import ChatWithModelTool, AskTeacherTool, ListModelsTool
|
from .model_interaction_tools import ChatWithModelTool, AskTeacherTool, ListModelsTool
|
||||||
from .bg_job_tools import ManageBgJobsTool
|
from .bg_job_tools import ManageBgJobsTool
|
||||||
from .session_tools import CreateSessionTool, ListSessionsTool, SendToSessionTool, ManageSessionTool
|
from .session_tools import CreateSessionTool, ListSessionsTool, SendToSessionTool, ManageSessionTool
|
||||||
@@ -48,6 +49,8 @@ TOOL_HANDLERS = {
|
|||||||
"suggest_document": SuggestDocumentTool().execute,
|
"suggest_document": SuggestDocumentTool().execute,
|
||||||
"manage_documents": ManageDocumentTool().execute,
|
"manage_documents": ManageDocumentTool().execute,
|
||||||
"get_workspace": GetWorkspaceTool().execute,
|
"get_workspace": GetWorkspaceTool().execute,
|
||||||
|
"ask_user": AskUserTool().execute,
|
||||||
|
"update_plan": UpdatePlanTool().execute,
|
||||||
"chat_with_model": ChatWithModelTool().execute,
|
"chat_with_model": ChatWithModelTool().execute,
|
||||||
"ask_teacher": AskTeacherTool().execute,
|
"ask_teacher": AskTeacherTool().execute,
|
||||||
"list_models": ListModelsTool().execute,
|
"list_models": ListModelsTool().execute,
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class AskUserTool:
|
||||||
|
async def execute(self, content, ctx):
|
||||||
|
"""
|
||||||
|
ask_user: the agent poses a multiple-choice question to the user to get a
|
||||||
|
decision/clarification. This is a pure UI-control marker — no subprocess,
|
||||||
|
no filesystem. It returns an `ask_user` payload that the agent loop turns
|
||||||
|
into an `ask_user` SSE event and then ENDS the turn, so the chat waits for
|
||||||
|
the user's selection (their choice arrives as the next message).
|
||||||
|
"""
|
||||||
|
question, options, multi = "", [], False
|
||||||
|
raw = (content or "").strip()
|
||||||
|
try:
|
||||||
|
parsed = json.loads(raw) if raw else {}
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
parsed = {}
|
||||||
|
|
||||||
|
if isinstance(parsed, dict):
|
||||||
|
question = str(parsed.get("question", "")).strip()
|
||||||
|
multi = bool(parsed.get("multi") or parsed.get("multiSelect"))
|
||||||
|
for opt in (parsed.get("options") or []):
|
||||||
|
if isinstance(opt, dict):
|
||||||
|
label = str(opt.get("label", "")).strip()
|
||||||
|
descr = str(opt.get("description", "")).strip()
|
||||||
|
elif isinstance(opt, str):
|
||||||
|
label, descr = opt.strip(), ""
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
if label:
|
||||||
|
options.append({"label": label, "description": descr})
|
||||||
|
else:
|
||||||
|
question = raw
|
||||||
|
|
||||||
|
if not question or len(options) < 2:
|
||||||
|
return "ask_user: invalid", {
|
||||||
|
"error": (
|
||||||
|
"ask_user needs a non-empty `question` and at least 2 `options` "
|
||||||
|
"(each an object with a `label`, optional `description`)."
|
||||||
|
),
|
||||||
|
"exit_code": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
options = options[:6] # keep the choice list sane
|
||||||
|
desc = f"ask_user: {question[:80]}"
|
||||||
|
labels = ", ".join(o["label"] for o in options)
|
||||||
|
result = {
|
||||||
|
"ask_user": {"question": question, "options": options, "multi": multi},
|
||||||
|
"output": f"Asked the user: {question}\nOptions: {labels}\nAwaiting their selection.",
|
||||||
|
"exit_code": 0,
|
||||||
|
}
|
||||||
|
logger.info("Tool executed: %s (%d options, multi=%s)", desc, len(options), multi)
|
||||||
|
return desc, result
|
||||||
|
|
||||||
|
class UpdatePlanTool:
|
||||||
|
async def execute(self, content, ctx):
|
||||||
|
"""
|
||||||
|
update_plan: the agent writes back to the active plan — tick an item done
|
||||||
|
or revise steps (e.g. when the user asks to change something). Pure UI
|
||||||
|
marker: returns a `plan_update` payload the agent loop turns into a
|
||||||
|
`plan_update` SSE event; the frontend replaces the stored plan and refreshes
|
||||||
|
the docked plan window. Does NOT end the turn.
|
||||||
|
"""
|
||||||
|
raw = (content or "").strip()
|
||||||
|
plan = ""
|
||||||
|
try:
|
||||||
|
parsed = json.loads(raw) if raw else {}
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
parsed = {}
|
||||||
|
|
||||||
|
if isinstance(parsed, dict) and parsed.get("plan"):
|
||||||
|
plan = str(parsed.get("plan", "")).strip()
|
||||||
|
else:
|
||||||
|
plan = raw
|
||||||
|
|
||||||
|
if not plan:
|
||||||
|
return "update_plan: invalid", {
|
||||||
|
"error": "update_plan needs a non-empty `plan` (the full updated checklist as markdown).",
|
||||||
|
"exit_code": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
plan = plan[:8192]
|
||||||
|
done = plan.count("- [x]") + plan.count("- [X]")
|
||||||
|
total = done + plan.count("- [ ]")
|
||||||
|
desc = f"update_plan: {done}/{total} done" if total else "update_plan"
|
||||||
|
result = {
|
||||||
|
"plan_update": {"plan": plan},
|
||||||
|
"output": f"Plan updated ({done}/{total} steps complete)." if total else "Plan updated.",
|
||||||
|
"exit_code": 0,
|
||||||
|
}
|
||||||
|
logger.info("Tool executed: %s", desc)
|
||||||
|
return desc, result
|
||||||
+34
-82
@@ -535,7 +535,7 @@ async def execute_tool_block(
|
|||||||
"""
|
"""
|
||||||
token = _active_workspace.set(workspace or None)
|
token = _active_workspace.set(workspace or None)
|
||||||
try:
|
try:
|
||||||
return await _execute_tool_block_impl(
|
output = await _execute_tool_block_impl(
|
||||||
block,
|
block,
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
disabled_tools=disabled_tools,
|
disabled_tools=disabled_tools,
|
||||||
@@ -543,6 +543,7 @@ async def execute_tool_block(
|
|||||||
progress_cb=progress_cb,
|
progress_cb=progress_cb,
|
||||||
tool_policy=tool_policy,
|
tool_policy=tool_policy,
|
||||||
)
|
)
|
||||||
|
return output
|
||||||
finally:
|
finally:
|
||||||
_active_workspace.reset(token)
|
_active_workspace.reset(token)
|
||||||
|
|
||||||
@@ -576,6 +577,22 @@ async def _execute_tool_block_impl(
|
|||||||
do_app_api,
|
do_app_api,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# HACK:
|
||||||
|
# This is a temporary workaround for a circular dependency between
|
||||||
|
# tool_execution.py and agent_tools.__init__.py.
|
||||||
|
#
|
||||||
|
# See issue #4277:
|
||||||
|
# refactor(tools): Move the registry from __init__.py into a
|
||||||
|
# dedicated registry.py module.
|
||||||
|
#
|
||||||
|
# Do not copy this pattern elsewhere. This import should be removed
|
||||||
|
# once the registry refactor is completed.
|
||||||
|
try:
|
||||||
|
agent_tools_mod = __import__("src.agent_tools", fromlist=["TOOL_HANDLERS"])
|
||||||
|
dynamic_handlers = getattr(agent_tools_mod, "TOOL_HANDLERS", {})
|
||||||
|
except ImportError:
|
||||||
|
dynamic_handlers = {}
|
||||||
|
|
||||||
tool = block.tool_type
|
tool = block.tool_type
|
||||||
content = block.content
|
content = block.content
|
||||||
|
|
||||||
@@ -639,86 +656,6 @@ async def _execute_tool_block_impl(
|
|||||||
logger.warning("Public tool policy blocked owner=%r tool=%s", owner, tool)
|
logger.warning("Public tool policy blocked owner=%r tool=%s", owner, tool)
|
||||||
return desc, result
|
return desc, result
|
||||||
|
|
||||||
# ask_user: the agent poses a multiple-choice question to the user to get a
|
|
||||||
# decision/clarification. This is a pure UI-control marker — no subprocess,
|
|
||||||
# no filesystem. It returns an `ask_user` payload that the agent loop turns
|
|
||||||
# into an `ask_user` SSE event and then ENDS the turn, so the chat waits for
|
|
||||||
# the user's selection (their choice arrives as the next message).
|
|
||||||
if tool == "ask_user":
|
|
||||||
question, options, multi = "", [], False
|
|
||||||
raw = (content or "").strip()
|
|
||||||
try:
|
|
||||||
parsed = json.loads(raw) if raw else {}
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
parsed = {}
|
|
||||||
if isinstance(parsed, dict):
|
|
||||||
question = str(parsed.get("question", "")).strip()
|
|
||||||
multi = bool(parsed.get("multi") or parsed.get("multiSelect"))
|
|
||||||
for opt in (parsed.get("options") or []):
|
|
||||||
if isinstance(opt, dict):
|
|
||||||
label = str(opt.get("label", "")).strip()
|
|
||||||
descr = str(opt.get("description", "")).strip()
|
|
||||||
elif isinstance(opt, str):
|
|
||||||
label, descr = opt.strip(), ""
|
|
||||||
else:
|
|
||||||
continue
|
|
||||||
if label:
|
|
||||||
options.append({"label": label, "description": descr})
|
|
||||||
else:
|
|
||||||
question = raw
|
|
||||||
if not question or len(options) < 2:
|
|
||||||
return "ask_user: invalid", {
|
|
||||||
"error": (
|
|
||||||
"ask_user needs a non-empty `question` and at least 2 `options` "
|
|
||||||
"(each an object with a `label`, optional `description`)."
|
|
||||||
),
|
|
||||||
"exit_code": 1,
|
|
||||||
}
|
|
||||||
options = options[:6] # keep the choice list sane
|
|
||||||
desc = f"ask_user: {question[:80]}"
|
|
||||||
labels = ", ".join(o["label"] for o in options)
|
|
||||||
result = {
|
|
||||||
"ask_user": {"question": question, "options": options, "multi": multi},
|
|
||||||
"output": f"Asked the user: {question}\nOptions: {labels}\nAwaiting their selection.",
|
|
||||||
"exit_code": 0,
|
|
||||||
}
|
|
||||||
logger.info("Tool executed: %s (%d options, multi=%s)", desc, len(options), multi)
|
|
||||||
return desc, result
|
|
||||||
|
|
||||||
# update_plan: the agent writes back to the active plan — tick an item done
|
|
||||||
# or revise steps (e.g. when the user asks to change something). Pure UI
|
|
||||||
# marker: returns a `plan_update` payload the agent loop turns into a
|
|
||||||
# `plan_update` SSE event; the frontend replaces the stored plan and refreshes
|
|
||||||
# the docked plan window. Does NOT end the turn.
|
|
||||||
if tool == "update_plan":
|
|
||||||
import json as _json
|
|
||||||
raw = (content or "").strip()
|
|
||||||
plan = ""
|
|
||||||
try:
|
|
||||||
parsed = _json.loads(raw) if raw else {}
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
parsed = {}
|
|
||||||
if isinstance(parsed, dict) and parsed.get("plan"):
|
|
||||||
plan = str(parsed.get("plan", "")).strip()
|
|
||||||
else:
|
|
||||||
# Plain-string call (raw checklist) or JSON without a usable `plan`.
|
|
||||||
plan = raw
|
|
||||||
if not plan:
|
|
||||||
return "update_plan: invalid", {
|
|
||||||
"error": "update_plan needs a non-empty `plan` (the full updated checklist as markdown).",
|
|
||||||
"exit_code": 1,
|
|
||||||
}
|
|
||||||
plan = plan[:8192]
|
|
||||||
done = plan.count("- [x]") + plan.count("- [X]")
|
|
||||||
total = done + plan.count("- [ ]")
|
|
||||||
desc = f"update_plan: {done}/{total} done" if total else "update_plan"
|
|
||||||
result = {
|
|
||||||
"plan_update": {"plan": plan},
|
|
||||||
"output": f"Plan updated ({done}/{total} steps complete)." if total else "Plan updated.",
|
|
||||||
"exit_code": 0,
|
|
||||||
}
|
|
||||||
logger.info("Tool executed: %s", desc)
|
|
||||||
return desc, result
|
|
||||||
|
|
||||||
# Background execution: a `bash` block whose first line is the `#!bg`
|
# Background execution: a `bash` block whose first line is the `#!bg`
|
||||||
# marker runs DETACHED — returns a job id immediately so the chat stream
|
# marker runs DETACHED — returns a job id immediately so the chat stream
|
||||||
@@ -902,9 +839,24 @@ async def _execute_tool_block_impl(
|
|||||||
else:
|
else:
|
||||||
desc = f"mcp: {tool}"
|
desc = f"mcp: {tool}"
|
||||||
result = {"error": "MCP manager not available", "exit_code": 1}
|
result = {"error": "MCP manager not available", "exit_code": 1}
|
||||||
|
|
||||||
|
|
||||||
|
elif tool in dynamic_handlers:
|
||||||
|
first_line = content.split(chr(10))[0][:80]
|
||||||
|
desc = f"registry: {tool} {first_line}".strip()
|
||||||
|
res = await _direct_fallback(tool, content, progress_cb=progress_cb)
|
||||||
|
|
||||||
|
if isinstance(res, tuple):
|
||||||
|
desc, result = res
|
||||||
|
else:
|
||||||
|
result = res or {"error": f"{tool}: execution failed", "exit_code": 1}
|
||||||
|
|
||||||
else:
|
else:
|
||||||
desc = f"unknown: {tool}"
|
desc = f"unknown: {tool}"
|
||||||
result = {"error": f"Unknown tool type: {tool}", "exit_code": 1}
|
result = {
|
||||||
|
"error": f"Unknown tool: {tool}",
|
||||||
|
"exit_code": 1
|
||||||
|
}
|
||||||
|
|
||||||
logger.info(f"Tool executed: {desc} -> exit_code={result.get('exit_code', 'n/a')}")
|
logger.info(f"Tool executed: {desc} -> exit_code={result.get('exit_code', 'n/a')}")
|
||||||
return desc, result
|
return desc, result
|
||||||
|
|||||||
Reference in New Issue
Block a user