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
- )