Consolidate goal tool

Replace the separate create, get, update, and storage modules with one multi-action goal tool while preserving the slash command, API, goal strip, and active-goal response behavior.

Alessandro committed Jul 18, 2026 at 21:03 UTC bba84d91dd7bd2bbaf8b80ac824796d3d29c4289
16 files changed +192 -274
plugins/_goal/AGENTS.md
+2 -2
@@ -8,11 +8,11 @@
8 ## Ownership
9
10 - `plugin.yaml` owns the always-enabled `_goal` plugin metadata.
11 -- `helpers/goals.py` owns file-backed goal storage under `usr/plugins/_goal/goals/` and goal status normalization.
11 +- `tools/goal.py` owns the single agent-facing goal tool, file-backed state under `usr/plugins/_goal/goals/`, and goal status normalization.
12 - `api/goal.py` owns the WebUI JSON API for reading, editing, pausing, resuming, and deleting goals.
13 - `commands/` owns the `/goal` slash command contributed to `_commands`.
14 - `webui/` and `extensions/webui/` own the composer goal strip and inline controls.
15 -- `tools/` and `prompts/` own agent-facing goal inspection, creation, and status update behavior.
15 +- `tools/goal.py` and `prompts/agent.system.tool.goal.md` own agent-facing goal inspection, creation, and status updates.
16 - `tools/response.py` overrides the core response tool so an active goal continues the current monologue.
17 - `extensions/python/message_loop_prompts_after/` owns injecting the active goal into agent context.
18
plugins/_goal/api/goal.py
+12 -12
@@ -2,7 +2,7 @@ from __future__ import annotations
2
3 from helpers.api import ApiHandler, Request, Response
4
5 -from plugins._goal.helpers import goals
5 +from plugins._goal.tools import goal
6
7
8 class Goal(ApiHandler):
@@ -12,7 +12,7 @@ class Goal(ApiHandler):
12
13 try:
14 if action == "get":
15 - return {"ok": True, "goal": goals.public_goal(goals.get_goal(context_id))}
15 + return {"ok": True, "goal": goal.public_goal(goal.get_goal(context_id))}
16 if action in {"set", "create"}:
17 return self._set(context_id, input)
18 if action == "update":
@@ -22,7 +22,7 @@ class Goal(ApiHandler):
22 if action == "resume":
23 return self._status(context_id, "active")
24 if action == "delete":
25 - goals.delete_goal(context_id)
25 + goal.delete_goal(context_id)
26 return {"ok": True, "goal": None}
27 except FileNotFoundError:
28 return Response(status=404, response="Goal not found")
@@ -32,17 +32,17 @@ class Goal(ApiHandler):
32 return Response(status=400, response=f"Unknown action: {action}")
33
34 def _set(self, context_id: str, input: dict) -> dict:
35 - goal = goals.create_goal(
35 + current_goal = goal.create_goal(
36 context_id,
37 str(input.get("objective") or ""),
38 created_by=str(input.get("created_by") or "user"),
39 token_budget=input.get("token_budget"),
40 )
41 - return {"ok": True, "goal": goals.public_goal(goal)}
41 + return {"ok": True, "goal": goal.public_goal(current_goal)}
42
43 def _update(self, context_id: str, input: dict) -> dict:
44 - current = goals.get_goal(context_id)
45 - goal = goals.update_goal(
44 + current = goal.get_goal(context_id)
45 + updated_goal = goal.update_goal(
46 context_id,
47 objective=input.get("objective") if "objective" in input else None,
48 status=input.get("status") if "status" in input else None,
@@ -51,14 +51,14 @@ class Goal(ApiHandler):
51 )
52 return {
53 "ok": True,
54 - "goal": goals.public_goal(goal),
54 + "goal": goal.public_goal(updated_goal),
55 "reactivated": (
56 current is not None
57 - and current.get("status") in goals.FINAL_STATUSES
58 - and goal.get("status") == "active"
57 + and current.get("status") in goal.FINAL_STATUSES
58 + and updated_goal.get("status") == "active"
59 ),
60 }
61
62 def _status(self, context_id: str, status: str) -> dict:
63 - goal = goals.update_goal(context_id, status=status)
64 - return {"ok": True, "goal": goals.public_goal(goal)}
63 + current_goal = goal.update_goal(context_id, status=status)
64 + return {"ok": True, "goal": goal.public_goal(current_goal)}
plugins/_goal/commands/goal_command.py
+17 -17
@@ -2,7 +2,7 @@ from __future__ import annotations
2
3 from typing import Any
4
5 -from plugins._goal.helpers import goals
5 +from plugins._goal.tools import goal
6
7
8 def run(payload: dict[str, Any]) -> dict[str, Any]:
@@ -18,33 +18,33 @@ def run(payload: dict[str, Any]) -> dict[str, Any]:
18
19 try:
20 if action in {"", "status", "show"}:
21 - return _show_markdown("Goal", goals.summarize_goal(goals.get_goal(context_id)))
21 + return _show_markdown("Goal", goal.summarize_goal(goal.get_goal(context_id)))
22 if action in {"pause", "paused"}:
23 - goal = goals.update_goal(context_id, status="paused")
24 - return _changed("Goal paused.", goal)
23 + current_goal = goal.update_goal(context_id, status="paused")
24 + return _changed("Goal paused.", current_goal)
25 if action in {"resume", "start", "active"}:
26 - goal = goals.update_goal(context_id, status="active")
27 - return _changed("Goal resumed.", goal)
26 + current_goal = goal.update_goal(context_id, status="active")
27 + return _changed("Goal resumed.", current_goal)
28 if action in {"delete", "clear", "remove"}:
29 - goals.delete_goal(context_id)
29 + goal.delete_goal(context_id)
30 return _changed("Goal deleted.", None)
31 if action in {"complete", "done"}:
32 - goal = goals.update_goal(context_id, status="complete")
33 - return _changed("Goal marked complete.", goal)
32 + current_goal = goal.update_goal(context_id, status="complete")
33 + return _changed("Goal marked complete.", current_goal)
34 if action == "blocked":
35 note = raw_args.split(None, 1)[1].strip() if len(tokens) > 1 else ""
36 - goal = goals.update_goal(context_id, status="blocked", note=note)
37 - return _changed("Goal marked blocked.", goal)
36 + current_goal = goal.update_goal(context_id, status="blocked", note=note)
37 + return _changed("Goal marked blocked.", current_goal)
38 if action == "edit":
39 objective = raw_args.split(None, 1)[1].strip() if len(tokens) > 1 else ""
40 - goal = goals.update_goal(context_id, objective=objective, status="active")
41 - return _changed("Goal updated.", goal)
40 + current_goal = goal.update_goal(context_id, objective=objective, status="active")
41 + return _changed("Goal updated.", current_goal)
42 if action in {"auto", "ask", "model"}:
43 hint = raw_args.split(None, 1)[1].strip() if len(tokens) > 1 else ""
44 return _auto_prompt(hint)
45
46 - goal = goals.create_goal(context_id, raw_args, created_by="user")
47 - return _changed("Goal set.", goal, send_text=raw_args)
46 + current_goal = goal.create_goal(context_id, raw_args, created_by="user")
47 + return _changed("Goal set.", current_goal, send_text=raw_args)
48 except FileNotFoundError:
49 return _effects(_toast("No goal is set for this chat.", level="error"))
50 except ValueError as error:
@@ -62,10 +62,10 @@ def _auto_prompt(hint: str) -> dict[str, Any]:
62 return {"text": prompt, "effects": []}
63
64
65 -def _changed(message: str, goal: dict[str, Any] | None, *, send_text: str = "") -> dict[str, Any]:
65 +def _changed(message: str, current_goal: dict[str, Any] | None, *, send_text: str = "") -> dict[str, Any]:
66 return _effects(
67 _toast(message),
68 - {"type": "goal_changed", "goal": goals.public_goal(goal)},
68 + {"type": "goal_changed", "goal": goal.public_goal(current_goal)},
69 {"type": "send_message", "text": send_text} if send_text else {},
70 )
71
plugins/_goal/extensions/python/message_loop_prompts_after/_50_include_goal.py
+8 -8
@@ -2,7 +2,7 @@ from __future__ import annotations
2
3 from agent import LoopData
4 from helpers.extension import Extension
5 -from plugins._goal.helpers import goals
5 +from plugins._goal.tools import goal
6
7
8 class IncludeGoal(Extension):
@@ -11,18 +11,18 @@ class IncludeGoal(Extension):
11 return
12
13 try:
14 - goal = goals.get_goal(self.agent.context.id)
14 + current_goal = goal.get_goal(self.agent.context.id)
15 except ValueError:
16 - goal = None
16 + current_goal = None
17
18 - if not goal or goal.get("status") != "active":
18 + if not current_goal or current_goal.get("status") != "active":
19 loop_data.extras_temporary.pop("current_goal", None)
20 return
21
22 loop_data.extras_temporary["current_goal"] = self.agent.read_prompt(
23 "agent.extras.goal.md",
24 - status=goal.get("status", ""),
25 - objective=goal.get("objective", ""),
26 - created_by=goal.get("created_by", ""),
27 - updated_at=goal.get("updated_at", ""),
24 + status=current_goal.get("status", ""),
25 + objective=current_goal.get("objective", ""),
26 + created_by=current_goal.get("created_by", ""),
27 + updated_at=current_goal.get("updated_at", ""),
28 )
plugins/_goal/helpers/__init__.py deleted
-1
@@ -1 +0,0 @@
1 -
plugins/_goal/prompts/agent.extras.goal.md
+1 -1
@@ -4,4 +4,4 @@ objective: {{objective}}
4 created by: {{created_by}}
5 updated: {{updated_at}}
6
7 -Keep working autonomously while this goal is active. Treat ordinary choices, confirmations, and recoverable external gates as yours to resolve safely within the user's scope; do not hand them back to the user. A `response` call is only an intermediate update and will not end the run. Call `update_goal` with `status="complete"` once you judge the objective achieved. Call `update_goal` with `status="blocked"` only after retrying viable alternatives and no safe, in-scope action can continue without unavailable information or an external-state change.
7 +Keep working autonomously while this goal is active. Treat ordinary choices, confirmations, and recoverable external gates as yours to resolve safely within the user's scope; do not hand them back to the user. A `response` call is only an intermediate update and will not end the run. Call `goal` with `action="update"` and `status="complete"` once you judge the objective achieved. Use `status="blocked"` only after retrying viable alternatives and no safe, in-scope action can continue without unavailable information or an external-state change.
plugins/_goal/prompts/agent.system.tool.create_goal.md deleted
-23
@@ -1,23 +0,0 @@
1 -### create_goal
2 -Create or replace the current chat goal.
3 -
4 -Use only when the user asks for a goal, asks you to manage a goal, or `/goal auto` asks you to create one.
5 -
6 -Args: `objective`, optional `token_budget`.
7 -
8 -Rules:
9 -- Create one concise objective that describes the current work, not a generic plan.
10 -- The new goal becomes active.
11 -- Do not create goals for casual replies or ordinary one-shot answers.
12 -
13 -Example:
14 -~~~json
15 -{
16 - "thoughts": ["The user asked me to manage this task as a goal."],
17 - "headline": "Creating goal",
18 - "tool_name": "create_goal",
19 - "tool_args": {
20 - "objective": "Add the built-in goal plugin with a Web UI strip and slash command"
21 - }
22 -}
23 -~~~
plugins/_goal/prompts/agent.system.tool.get_goal.md deleted
-10
@@ -1,10 +0,0 @@
1 -### get_goal
2 -Inspect the current chat goal.
3 -
4 -Use this when the user asks about the goal, asks you to manage a goal, or you need to check whether a goal already exists before creating one.
5 -
6 -Args: none.
7 -
8 -Rules:
9 -- Do not invent a goal if none exists.
10 -- If a goal is paused, complete, or blocked, treat it as state to report unless the user asks you to resume or replace it.
plugins/_goal/prompts/agent.system.tool.goal.md new
+24
@@ -0,0 +1,24 @@
1 +### goal
2 +Inspect, create, or finish the current chat goal.
3 +
4 +Use this only when the user asks for a goal or asks you to manage one. Do not create goals for casual replies or ordinary one-shot answers.
5 +
6 +Actions:
7 +- `get`: inspect the current goal; use this before creating one when its state is unknown.
8 +- `create`: make the given concise `objective` active; optional positive `token_budget`.
9 +- `update`: mark the current goal with `status` `complete` or `blocked`; optional revised `objective` and `note`.
10 +
11 +Pause, resume, edit, and delete are user controls. Mark `complete` only after achieving the objective; mark `blocked` only after viable alternatives are exhausted and work cannot continue without user input or an external-state change.
12 +
13 +Example:
14 +~~~json
15 +{
16 + "thoughts": ["The user asked me to manage this task as a goal."],
17 + "headline": "Creating goal",
18 + "tool_name": "goal",
19 + "tool_args": {
20 + "action": "create",
21 + "objective": "Add the built-in goal plugin with a Web UI strip and slash command"
22 + }
23 +}
24 +~~~
plugins/_goal/prompts/agent.system.tool.update_goal.md deleted
-11
@@ -1,11 +0,0 @@
1 -### update_goal
2 -Mark the current chat goal complete or blocked.
3 -
4 -Use when the active goal is actually achieved, or when progress is genuinely blocked by missing user input or an external-state change.
5 -
6 -Args: `status` (`complete` or `blocked`), optional `objective`, optional `note`.
7 -
8 -Rules:
9 -- Mark `complete` only when the objective has been achieved.
10 -- Mark `blocked` only when meaningful progress cannot continue without user input or an external-state change.
11 -- Pause, resume, edit, and delete are user controls; do not claim to perform them with this tool.
plugins/_goal/tests/test_goal_plugin.py
+30 -32
@@ -6,20 +6,18 @@ from types import SimpleNamespace
6 import pytest
7
8 from helpers import files
9 -from plugins._goal.api.goal import Goal
9 +from plugins._goal.api.goal import Goal as GoalApi
10 from plugins._goal.commands import goal_command
11 -from plugins._goal.helpers import goals
12 -from plugins._goal.tools.create_goal import CreateGoal
13 -from plugins._goal.tools.get_goal import GetGoal
11 +from plugins._goal.tools import goal
12 +from plugins._goal.tools.goal import GoalTool
13 from plugins._goal.tools.response import ResponseTool
15 -from plugins._goal.tools.update_goal import UpdateGoal
14
15
16 @pytest.fixture()
17 def context_id():
18 context_id = f"goal-test-{uuid.uuid4().hex}"
19 yield context_id
22 - goals.delete_goal(context_id)
20 + goal.delete_goal(context_id)
21
22
23 def _payload(context_id: str, command_text: str) -> dict:
@@ -32,47 +30,47 @@ def _payload(context_id: str, command_text: str) -> dict:
30
31
32 def test_goal_storage_round_trip(context_id: str):
35 - goal = goals.create_goal(context_id, "Ship the goal plugin", token_budget=1200)
33 + current_goal = goal.create_goal(context_id, "Ship the goal plugin", token_budget=1200)
34
37 - loaded = goals.get_goal(context_id)
38 - assert loaded == goal
35 + loaded = goal.get_goal(context_id)
36 + assert loaded == current_goal
37 assert loaded["status"] == "active"
38 assert loaded["token_budget"] == 1200
39 assert loaded["active_since"]
40 assert loaded["elapsed_seconds"] == 0
41
44 - updated = goals.update_goal(context_id, status="paused", objective="Polish the goal strip")
42 + updated = goal.update_goal(context_id, status="paused", objective="Polish the goal strip")
43 assert updated["status"] == "paused"
44 assert updated["objective"] == "Polish the goal strip"
45 assert updated["active_since"] == ""
46 paused_seconds = updated["elapsed_seconds"]
47
50 - resumed = goals.update_goal(context_id, status="active")
48 + resumed = goal.update_goal(context_id, status="active")
49 assert resumed["status"] == "active"
50 assert resumed["active_since"]
51 assert resumed["elapsed_seconds"] == paused_seconds
52
55 - goals.delete_goal(context_id)
56 - assert goals.get_goal(context_id) is None
53 + goal.delete_goal(context_id)
54 + assert goal.get_goal(context_id) is None
55
56
57 def test_goal_command_sets_pauses_resumes_and_deletes(context_id: str):
58 created = goal_command.run(_payload(context_id, "/goal Add current goal support"))
59 assert created["effects"][0]["message"] == "Goal set."
60 assert created["effects"][2] == {"type": "send_message", "text": "Add current goal support"}
63 - assert goals.get_goal(context_id)["objective"] == "Add current goal support"
61 + assert goal.get_goal(context_id)["objective"] == "Add current goal support"
62
63 paused = goal_command.run(_payload(context_id, "/goal pause"))
64 assert paused["effects"][0]["message"] == "Goal paused."
67 - assert goals.get_goal(context_id)["status"] == "paused"
65 + assert goal.get_goal(context_id)["status"] == "paused"
66
67 resumed = goal_command.run(_payload(context_id, "/goal resume"))
68 assert resumed["effects"][0]["message"] == "Goal resumed."
71 - assert goals.get_goal(context_id)["status"] == "active"
69 + assert goal.get_goal(context_id)["status"] == "active"
70
71 deleted = goal_command.run(_payload(context_id, "/goal delete"))
72 assert deleted["effects"][0]["message"] == "Goal deleted."
75 - assert goals.get_goal(context_id) is None
73 + assert goal.get_goal(context_id) is None
74
75
76 def test_goal_auto_fills_prompt(context_id: str):
@@ -84,12 +82,12 @@ def test_goal_auto_fills_prompt(context_id: str):
82
83
84 def test_goal_files_stay_under_user_plugin_state(context_id: str):
87 - goals.create_goal(context_id, "Keep state in usr")
85 + goal.create_goal(context_id, "Keep state in usr")
86 goal_path = files.get_abs_path(
87 files.USER_DIR,
88 files.PLUGINS_DIR,
91 - goals.PLUGIN_NAME,
92 - goals.GOALS_DIR,
89 + goal.PLUGIN_NAME,
90 + goal.GOALS_DIR,
91 f"{context_id}.json",
92 )
93
@@ -98,7 +96,7 @@ def test_goal_files_stay_under_user_plugin_state(context_id: str):
96
97 @pytest.mark.asyncio
98 async def test_goal_api_and_agent_tools(context_id: str):
101 - handler = object.__new__(Goal)
99 + handler = object.__new__(GoalApi)
100 created = await handler.process(
101 {
102 "action": "set",
@@ -111,18 +109,18 @@ async def test_goal_api_and_agent_tools(context_id: str):
109 assert created["goal"]["objective"] == "Exercise API path"
110
111 fake_agent = SimpleNamespace(context=SimpleNamespace(id=context_id))
114 - get_tool = GetGoal(fake_agent, "get_goal", None, {}, "", None)
112 + get_tool = GoalTool(fake_agent, "goal", None, {}, "", None)
113 get_response = await get_tool.execute()
114 assert "Exercise API path" in get_response.message
115
118 - update_tool = UpdateGoal(fake_agent, "update_goal", None, {}, "", None)
119 - update_response = await update_tool.execute(status="complete")
116 + update_tool = GoalTool(fake_agent, "goal", None, {}, "", None)
117 + update_response = await update_tool.execute(action="update", status="complete")
118 assert "Status: complete" in update_response.message
119
122 - create_tool = CreateGoal(fake_agent, "create_goal", None, {}, "", None)
123 - create_response = await create_tool.execute(objective="Exercise tool path")
120 + create_tool = GoalTool(fake_agent, "goal", None, {}, "", None)
121 + create_response = await create_tool.execute(action="create", objective="Exercise tool path")
122 assert "Goal created: Exercise tool path" == create_response.message
125 - assert goals.get_goal(context_id)["created_by"] == "model"
123 + assert goal.get_goal(context_id)["created_by"] == "model"
124
125
126 @pytest.mark.parametrize("terminal_status", ["blocked", "complete"])
@@ -131,10 +129,10 @@ async def test_editing_terminal_goal_requests_agent_reactivation(
129 context_id: str,
130 terminal_status: str,
131 ):
134 - goals.create_goal(context_id, "Initial goal")
135 - goals.update_goal(context_id, status=terminal_status)
132 + goal.create_goal(context_id, "Initial goal")
133 + goal.update_goal(context_id, status=terminal_status)
134
137 - response = await object.__new__(Goal).process(
135 + response = await object.__new__(GoalApi).process(
136 {
137 "action": "update",
138 "context_id": context_id,
@@ -151,7 +149,7 @@ async def test_editing_terminal_goal_requests_agent_reactivation(
149
150 @pytest.mark.asyncio
151 async def test_active_goal_keeps_response_tool_running(context_id: str):
154 - goals.create_goal(context_id, "Keep going")
152 + goal.create_goal(context_id, "Keep going")
153 recorded = []
154 fake_agent = SimpleNamespace(
155 context=SimpleNamespace(id=context_id),
@@ -178,7 +176,7 @@ async def test_active_goal_keeps_response_tool_running(context_id: str):
176 )
177 ]
178
181 - goals.update_goal(context_id, status="complete")
179 + goal.update_goal(context_id, status="complete")
180 response = await tool.execute()
181 assert response.break_loop is True
182 assert response.message == "Can you decide?"
plugins/_goal/tools/create_goal.py deleted
-23
@@ -1,23 +0,0 @@
1 -from __future__ import annotations
2 -
3 -from helpers.tool import Response, Tool
4 -from plugins._goal.helpers import goals
5 -
6 -
7 -class CreateGoal(Tool):
8 - async def execute(
9 - self,
10 - objective: str = "",
11 - token_budget: int | None = None,
12 - **kwargs,
13 - ) -> Response:
14 - goal = goals.create_goal(
15 - self.agent.context.id,
16 - objective,
17 - created_by="model",
18 - token_budget=token_budget,
19 - )
20 - return Response(
21 - message=f"Goal created: {goal['objective']}",
22 - break_loop=False,
23 - )
plugins/_goal/tools/get_goal.py deleted
-10
@@ -1,10 +0,0 @@
1 -from __future__ import annotations
2 -
3 -from helpers.tool import Response, Tool
4 -from plugins._goal.helpers import goals
5 -
6 -
7 -class GetGoal(Tool):
8 - async def execute(self, **kwargs) -> Response:
9 - goal = goals.get_goal(self.agent.context.id)
10 - return Response(message=goals.summarize_goal(goal), break_loop=False)
plugins/_goal/tools/goal.py renamed
+93 -89
@@ -7,6 +7,7 @@ from pathlib import Path
7 from typing import Any
8
9 from helpers import files
10 +from helpers.tool import Response, Tool
11
12
13 PLUGIN_NAME = "_goal"
@@ -16,6 +17,55 @@ FINAL_STATUSES = {"complete", "blocked"}
17 VALID_STATUSES = ACTIVE_STATUSES | FINAL_STATUSES
18
19
20 +class GoalTool(Tool):
21 + async def execute(
22 + self,
23 + action: str = "",
24 + objective: str = "",
25 + status: str = "",
26 + note: str = "",
27 + token_budget: int | None = None,
28 + **kwargs,
29 + ) -> Response:
30 + action = str(action or self.args.get("action") or "get").strip().lower()
31 + context_id = self.agent.context.id
32 +
33 + try:
34 + if action in {"get", "show", "status"}:
35 + return Response(message=summarize_goal(get_goal(context_id)), break_loop=False)
36 + if action in {"create", "set"}:
37 + goal = create_goal(
38 + context_id,
39 + objective,
40 + created_by="model",
41 + token_budget=token_budget,
42 + )
43 + return Response(message=f"Goal created: {goal['objective']}", break_loop=False)
44 + if action in {"complete", "blocked"}:
45 + status = action
46 + if action in {"update", "complete", "blocked"}:
47 + status = str(status).strip().lower()
48 + if status not in FINAL_STATUSES:
49 + return Response(
50 + message="Model-managed goal updates may only mark goals complete or blocked.",
51 + break_loop=False,
52 + )
53 + goal = update_goal(
54 + context_id,
55 + status=status,
56 + objective=objective or None,
57 + note=note or None,
58 + )
59 + return Response(message=summarize_goal(goal), break_loop=False)
60 + except (FileNotFoundError, ValueError) as error:
61 + return Response(message=str(error), break_loop=False)
62 +
63 + return Response(
64 + message="Unknown goal action. Supported actions: get, create, update.",
65 + break_loop=False,
66 + )
67 +
68 +
69 def get_goal(context_id: str) -> dict[str, Any] | None:
70 context_id = _require_context_id(context_id)
71 path = _goal_path(context_id)
@@ -30,31 +80,7 @@ def get_goal(context_id: str) -> dict[str, Any] | None:
80 return None
81
82 goal = _normalize_goal(raw, context_id=context_id)
33 - if not goal.get("objective"):
34 - return None
35 - return goal
36 -
37 -
38 -def list_goals() -> list[dict[str, Any]]:
39 - directory = _goals_dir()
40 - if not Path(directory).is_dir():
41 - return []
42 -
43 - goals: list[dict[str, Any]] = []
44 - for goal_file in sorted(Path(directory).glob("*.json")):
45 - try:
46 - raw = json.loads(files.read_file(str(goal_file)))
47 - except (OSError, json.JSONDecodeError):
48 - continue
49 - if not isinstance(raw, dict):
50 - continue
51 - context_id = str(raw.get("context_id") or "").strip()
52 - if not context_id:
53 - continue
54 - goal = _normalize_goal(raw, context_id=context_id)
55 - if goal.get("objective"):
56 - goals.append(goal)
57 - return goals
83 + return goal if goal.get("objective") else None
84
85
86 def create_goal(
@@ -101,17 +127,11 @@ def update_goal(
127 raise FileNotFoundError("Goal not found")
128
129 if objective is not None:
104 - cleaned_objective = _clean_objective(objective)
105 - if not cleaned_objective:
106 - raise ValueError("Goal objective is required")
107 - goal["objective"] = cleaned_objective
108 -
130 + goal["objective"] = _required_objective(objective)
131 if status is not None:
132 _apply_status(goal, _normalize_status(status))
111 -
133 if note is not None:
134 goal["note"] = str(note or "").strip()
114 -
135 if token_budget is not None:
136 goal["token_budget"] = _clean_token_budget(token_budget)
137
@@ -121,8 +141,7 @@ def update_goal(
141
142
143 def delete_goal(context_id: str) -> None:
124 - context_id = _require_context_id(context_id)
125 - files.delete_file(_goal_path(context_id))
144 + files.delete_file(_goal_path(_require_context_id(context_id)))
145
146
147 def public_goal(goal: dict[str, Any] | None) -> dict[str, Any] | None:
@@ -146,15 +165,14 @@ def summarize_goal(goal: dict[str, Any] | None) -> str:
165 if not goal:
166 return "No goal is set for this chat."
167
149 - status = str(goal.get("status") or "active")
150 - objective = str(goal.get("objective") or "").strip()
151 - elapsed = _format_elapsed(_elapsed_seconds(goal))
152 - updated = str(goal.get("updated_at") or "").strip()
153 - lines = [f"Status: {status}", f"Goal: {objective}", f"Active time: {elapsed}"]
154 - if updated:
168 + lines = [
169 + f"Status: {goal.get('status') or 'active'}",
170 + f"Goal: {str(goal.get('objective') or '').strip()}",
171 + f"Active time: {_format_elapsed(_elapsed_seconds(goal))}",
172 + ]
173 + if updated := str(goal.get("updated_at") or "").strip():
174 lines.append(f"Updated: {updated}")
156 - note = str(goal.get("note") or "").strip()
157 - if note:
175 + if note := str(goal.get("note") or "").strip():
176 lines.append(f"Note: {note}")
177 return "\n".join(lines)
178
@@ -168,9 +186,7 @@ def _write_goal(goal: dict[str, Any]) -> None:
186
187 def _normalize_goal(raw: dict[str, Any], *, context_id: str) -> dict[str, Any]:
188 status = str(raw.get("status") or "active").strip().lower()
171 - if status not in VALID_STATUSES:
172 - status = "active"
173 -
189 + status = status if status in VALID_STATUSES else "active"
190 created_at = str(raw.get("created_at") or "")
191 updated_at = str(raw.get("updated_at") or "")
192 active_since = str(raw.get("active_since") or "")
@@ -194,12 +210,12 @@ def _normalize_goal(raw: dict[str, Any], *, context_id: str) -> dict[str, Any]:
210 }
211
212
197 -def _goals_dir() -> str:
198 - return files.get_abs_path(files.USER_DIR, files.PLUGINS_DIR, PLUGIN_NAME, GOALS_DIR)
199 -
200 -
213 def _goal_path(context_id: str) -> str:
202 - return files.get_abs_path(_goals_dir(), f"{_safe_context_id(context_id)}.json")
214 + directory = files.get_abs_path(files.USER_DIR, files.PLUGINS_DIR, PLUGIN_NAME, GOALS_DIR)
215 + safe = re.sub(r"[^A-Za-z0-9_.-]+", "_", context_id).strip("._")[:180]
216 + if not safe:
217 + raise ValueError("A chat context is required")
218 + return files.get_abs_path(directory, f"{safe}.json")
219
220
221 def _require_context_id(context_id: str) -> str:
@@ -209,37 +225,35 @@ def _require_context_id(context_id: str) -> str:
225 return context_id
226
227
212 -def _safe_context_id(context_id: str) -> str:
213 - safe = re.sub(r"[^A-Za-z0-9_.-]+", "_", context_id).strip("._")
214 - if not safe:
215 - raise ValueError("A chat context is required")
216 - return safe[:180]
228 +def _required_objective(value: str) -> str:
229 + objective = _clean_objective(value)
230 + if not objective:
231 + raise ValueError("Goal objective is required")
232 + return objective
233
234
219 -def _clean_objective(objective: str) -> str:
220 - return re.sub(r"\s+", " ", str(objective or "")).strip()
235 +def _clean_objective(value: str) -> str:
236 + return re.sub(r"\s+", " ", str(value or "")).strip()
237
238
239 def _normalize_status(status: str) -> str:
224 - cleaned = str(status or "").strip().lower()
225 - if cleaned not in VALID_STATUSES:
240 + status = str(status or "").strip().lower()
241 + if status not in VALID_STATUSES:
242 raise ValueError("Goal status must be active, paused, complete, or blocked")
227 - return cleaned
243 + return status
244
245
246 def _clean_created_by(created_by: str) -> str:
231 - cleaned = str(created_by or "").strip().lower()
232 - return cleaned if cleaned in {"user", "model"} else "user"
247 + created_by = str(created_by or "").strip().lower()
248 + return created_by if created_by in {"user", "model"} else "user"
249
250
251 def _clean_token_budget(token_budget: Any) -> int | None:
236 - if token_budget in (None, ""):
237 - return None
252 try:
239 - value = int(token_budget)
253 + token_budget = int(token_budget)
254 except (TypeError, ValueError):
255 return None
242 - return value if value > 0 else None
256 + return token_budget if token_budget > 0 else None
257
258
259 def _apply_status(goal: dict[str, Any], status: str) -> None:
@@ -253,51 +267,41 @@ def _apply_status(goal: dict[str, Any], status: str) -> None:
267
268
269 def _elapsed_seconds(goal: dict[str, Any]) -> int:
256 - seconds = _clean_elapsed_seconds(goal.get("elapsed_seconds"))
270 + elapsed = _clean_elapsed_seconds(goal.get("elapsed_seconds"))
271 if str(goal.get("status") or "active") == "active":
258 - seconds += _seconds_between(str(goal.get("active_since") or ""), _now())
259 - return seconds
272 + elapsed += _seconds_between(str(goal.get("active_since") or ""), _now())
273 + return elapsed
274
275
276 def _clean_elapsed_seconds(value: Any) -> int:
277 try:
264 - seconds = int(value)
278 + return max(0, int(value))
279 except (TypeError, ValueError):
280 return 0
267 - return max(0, seconds)
281
282
283 def _seconds_between(start: str, end: str) -> int:
271 - start_dt = _parse_time(start)
272 - end_dt = _parse_time(end)
273 - if not start_dt or not end_dt:
274 - return 0
275 - return max(0, int((end_dt - start_dt).total_seconds()))
284 + start_dt, end_dt = _parse_time(start), _parse_time(end)
285 + return max(0, int((end_dt - start_dt).total_seconds())) if start_dt and end_dt else 0
286
287
288 def _parse_time(value: str) -> datetime | None:
279 - text = str(value or "").strip()
280 - if not text:
289 + value = str(value or "").strip()
290 + if not value:
291 return None
282 - if text.endswith("Z"):
283 - text = f"{text[:-1]}+00:00"
292 + if value.endswith("Z"):
293 + value = f"{value[:-1]}+00:00"
294 try:
285 - parsed = datetime.fromisoformat(text)
295 + parsed = datetime.fromisoformat(value)
296 except ValueError:
297 return None
288 - if parsed.tzinfo is None:
289 - parsed = parsed.replace(tzinfo=timezone.utc)
290 - return parsed.astimezone(timezone.utc)
298 + return parsed.replace(tzinfo=timezone.utc) if parsed.tzinfo is None else parsed.astimezone(timezone.utc)
299
300
301 def _format_elapsed(seconds: int) -> str:
302 hours, remainder = divmod(max(0, int(seconds)), 3600)
303 minutes, seconds = divmod(remainder, 60)
296 - if hours:
297 - return f"{hours}h {minutes}m"
298 - if minutes:
299 - return f"{minutes}m {seconds}s"
300 - return f"{seconds}s"
304 + return f"{hours}h {minutes}m" if hours else f"{minutes}m {seconds}s" if minutes else f"{seconds}s"
305
306
307 def _now() -> str:
plugins/_goal/tools/response.py
+5 -4
@@ -1,9 +1,10 @@
1 from __future__ import annotations
2
3 from helpers.tool import Response
4 -from plugins._goal.helpers import goals
4 from tools import response as core_response
5
6 +from plugins._goal.tools import goal
7 +
8
9 _CONTINUE_MARKER = "_goal_continue"
10
@@ -11,14 +12,14 @@ _CONTINUE_MARKER = "_goal_continue"
12 class ResponseTool(core_response.ResponseTool):
13 async def execute(self, **kwargs) -> Response:
14 response = await super().execute(**kwargs)
14 - goal = goals.get_goal(self.agent.context.id)
15 - if not goal or goal.get("status") != "active":
15 + current_goal = goal.get_goal(self.agent.context.id)
16 + if not current_goal or current_goal.get("status") != "active":
17 return response
18
19 response.break_loop = False
20 response.message = (
21 "Goal still active. Continue working autonomously and make safe, in-scope "
21 - "choices yourself. Call update_goal complete when satisfied, or blocked only "
22 + "choices yourself. Call goal update complete when satisfied, or blocked only "
23 "when no viable in-scope path remains."
24 )
25 response.additional = {**(response.additional or {}), _CONTINUE_MARKER: True}
plugins/_goal/tools/update_goal.py deleted
-31
@@ -1,31 +0,0 @@
1 -from __future__ import annotations
2 -
3 -from helpers.tool import Response, Tool
4 -from plugins._goal.helpers import goals
5 -
6 -
7 -class UpdateGoal(Tool):
8 - async def execute(
9 - self,
10 - status: str = "",
11 - objective: str = "",
12 - note: str = "",
13 - **kwargs,
14 - ) -> Response:
15 - normalized_status = str(status or "").strip().lower()
16 - if normalized_status not in {"complete", "blocked"}:
17 - return Response(
18 - message="Model-managed goal updates may only mark goals complete or blocked.",
19 - break_loop=False,
20 - )
21 -
22 - goal = goals.update_goal(
23 - self.agent.context.id,
24 - status=normalized_status,
25 - objective=objective if objective else None,
26 - note=note if note else None,
27 - )
28 - return Response(
29 - message=goals.summarize_goal(goal),
30 - break_loop=False,
31 - )