Add protocol prompt area before history

Introduce a new prompt "protocol" separate from extras: add LoopData.protocol_temporary and protocol_persistent, include protocol contents before message history during prompt construction, and clear temporary protocol data each turn. Add helper _build_context_message to render protocol/extras, update response input conversion to include protocol, and move project instructions & active/loaded skills injection into protocol. Update docs, prompts, plugin metadata, the SkillsTool message, and add tests to verify protocol placement and behavior.

frdel committed Jun 18, 2026 at 21:42 UTC afdc3aeb44b29b8d59433fa00fbe8e49a01974eb
24 files changed +236 -53
AGENTS.md
+1 -1
@@ -96,7 +96,7 @@ When running in Docker, Agent Zero uses two distinct Python runtimes to isolate
96 ```
97
98 Key Files:
99 -- agent.py: Defines AgentContext and the main Agent class.
99 +- agent.py: Defines AgentContext, LoopData virtual prompt areas (Protocol before history and Extras after history), and the main Agent class.
100 - helpers/plugins.py: Plugin discovery and configuration logic.
101 - webui/js/AlpineStore.js: Store factory for reactive frontend state.
102 - helpers/api.py: Base class for all API endpoints.
agent.py
+43 -25
@@ -338,6 +338,8 @@ class LoopData:
338 self.system = []
339 self.user_message: history.Message | None = None
340 self.history_output: list[history.OutputMessage] = []
341 + self.protocol_temporary: OrderedDict[str, history.MessageContent] = OrderedDict()
342 + self.protocol_persistent: OrderedDict[str, history.MessageContent] = OrderedDict()
343 self.extras_temporary: OrderedDict[str, history.MessageContent] = OrderedDict()
344 self.extras_persistent: OrderedDict[str, history.MessageContent] = OrderedDict()
345 self.last_response = ""
@@ -580,24 +582,28 @@ class Agent:
582 # concatenate system prompt
583 system_text = "\n\n".join(loop_data.system)
584
583 - # join extras
584 - extras = history.Message( # type: ignore[abstract]
585 - False,
586 - content=self.read_prompt(
587 - "agent.context.extras.md",
588 - extras=dirty_json.stringify(
589 - {**loop_data.extras_persistent, **loop_data.extras_temporary}
590 - ),
591 - ),
592 - ).output()
585 + # join protocol and extras
586 + protocol = self._build_context_message(
587 + "agent.context.protocol.md",
588 + "protocol",
589 + {**loop_data.protocol_persistent, **loop_data.protocol_temporary},
590 + include_empty=False,
591 + )
592 + extras = self._build_context_message(
593 + "agent.context.extras.md",
594 + "extras",
595 + {**loop_data.extras_persistent, **loop_data.extras_temporary},
596 + include_empty=True,
597 + )
598 + loop_data.protocol_temporary.clear()
599 loop_data.extras_temporary.clear()
600
595 - # convert history + extras to LLM format
601 + # convert protocol + history + extras to LLM format
602 history_langchain: list[BaseMessage] = history.output_langchain(
597 - loop_data.history_output + extras
603 + protocol + loop_data.history_output + extras
604 )
605
600 - # build full prompt from system prompt, message history and extrS
606 + # build full prompt from system prompt, protocol, message history and extras
607 full_prompt: list[BaseMessage] = [
608 SystemMessage(content=system_text),
609 *history_langchain,
@@ -615,6 +621,24 @@ class Agent:
621
622 return full_prompt
623
624 + def _build_context_message(
625 + self,
626 + prompt_file: str,
627 + variable_name: str,
628 + values: dict[str, history.MessageContent],
629 + include_empty: bool,
630 + ) -> list[history.OutputMessage]:
631 + if not include_empty and not values:
632 + return []
633 +
634 + return history.Message( # type: ignore[abstract]
635 + False,
636 + content=self.read_prompt(
637 + prompt_file,
638 + **{variable_name: dirty_json.stringify(values)},
639 + ),
640 + ).output()
641 +
642 @extension.extensible
643 async def handle_exception(self, location: str, exception: Exception):
644 if exception:
@@ -920,9 +944,9 @@ class Agent:
944 model,
945 history_counter,
946 )
923 - call_data["responses_local_input_items"] = (
924 - self._responses_static_prefix_items(model, messages)
925 - + self._responses_input_items_since(model, 0)
947 + call_data["responses_local_input_items"] = self._responses_prompt_input_items(
948 + model,
949 + messages,
950 )
951
952 await extension.call_extensions_async(
@@ -1014,18 +1038,12 @@ class Agent:
1038 return ResponsesTransport.input_from_messages(converted)
1039 return []
1040
1017 - def _responses_static_prefix_items(
1041 + def _responses_prompt_input_items(
1042 self, model: Any, messages: list[BaseMessage]
1043 ) -> list[dict[str, Any]]:
1020 - prefix: list[BaseMessage] = []
1021 - for message in messages:
1022 - if isinstance(message, SystemMessage):
1023 - prefix.append(message)
1024 - continue
1025 - break
1026 - if not prefix or not hasattr(model, "_convert_messages"):
1044 + if not hasattr(model, "_convert_messages"):
1045 return []
1028 - converted = model._convert_messages(prefix)
1046 + converted = model._convert_messages(messages)
1047 return ResponsesTransport.input_from_messages(converted)
1048
1049 def _remember_llm_result_state(
docs/README.md
+1 -1
@@ -27,7 +27,7 @@ docs focus on practical setup, screenshots, and user workflows.
27 - **[Desktop Guide](guides/desktop.md):** Use the built-in Linux desktop, GUI apps, and LibreOffice Writer/Calc/Impress Cowork.
28 - **[A0 CLI Connector](guides/a0-cli-connector.md):** Terminal-first host connector for Agent Zero, with screenshots of the host picker, connected shell, command palette, and Browser modes.
29 - **[Create a Small Plugin](guides/create-plugin.md):** Build and review a tiny Web UI plugin that adds an unread dot to the chat list.
30 -- **[Skills Guide](guides/skills.md):** Open the Skills selector, add active skills, and remove prompt extras you no longer need.
30 +- **[Skills Guide](guides/skills.md):** Open the Skills selector, add active skills, and remove prompt protocol entries you no longer need.
31 - **[Agent Profiles](guides/agent-profiles.md):** Switch the current chat profile or create a new guided profile from the chat input.
32 - **[Model Presets](guides/model-presets.md):** Create simple named shortcuts for model setups.
33 - **[Memory Guide](guides/memory.md):** Search, edit, delete, and curate memories so useful context does not become stale noise.
docs/guides/model-presets.md
+1 -1
@@ -83,7 +83,7 @@ You can always rename them later.
83 | **Model Preset** | Which models power the chat. |
84 | **Agent Profile** | The agent's role, tone, and prompt behavior. |
85 | **Project** | Workspace, files, memory, secrets, and project instructions. |
86 -| **Skill** | A specific procedure added to prompt extras. |
86 +| **Skill** | A specific procedure added to prompt protocol. |
87
88 For example, you can use the same "Researcher" Agent Profile with a cheaper
89 preset for simple questions and a stronger preset for difficult investigations.
docs/guides/skills.md
+2 -2
@@ -29,7 +29,7 @@ Click a skill to add it. Active skills are shown at the top of the selector.
29 To remove a skill, use the remove button in **Active skills** or uncheck it in
30 the list.
31
32 -Active skills are added to the **Extras** part of the system prompt. That means
32 +Active skills are added to the **Protocol** part of the prompt. That means
33 Agent Zero sees them every turn while they are active.
34
35 > [!TIP]
@@ -56,7 +56,7 @@ usually easier for the agent to follow.
56
57 | Control | What it changes |
58 | --- | --- |
59 -| **Skills** | Adds a specific procedure to the current prompt extras. |
59 +| **Skills** | Adds a specific procedure to the current prompt protocol. |
60 | **Agent Profiles** | Changes the broader role and behavior of the chat. |
61 | **Projects** | Adds workspace, files, memory, secrets, and project instructions. |
62
docs/guides/usage.md
+1 -1
@@ -74,7 +74,7 @@ Use the selector to add or remove active skills.
74
75 ![Skills selector](../res/usage/webui/skills-selector-checked.png)
76
77 -Active skills are added to the **Extras** part of the system prompt, so keep the
77 +Active skills are added to the **Protocol** part of the prompt, so keep the
78 list short and intentional. See the [Skills guide](skills.md).
79
80 ### Agent Profiles
docs/quickstart.md
+1 -1
@@ -141,7 +141,7 @@ Explains how to search, edit, delete, export, and curate memories before stale c
141
142 ### [Open A0 Skills Guide](guides/skills.md)
143
144 -Shows the chat input **+** menu, the Skills selector, and how active skills are added to prompt extras.
144 +Shows the chat input **+** menu, the Skills selector, and how active skills are added to prompt protocol.
145
146 ### [Open A0 Agent Profiles Guide](guides/agent-profiles.md)
147
extensions/python/AGENTS.md
+1 -1
@@ -44,7 +44,7 @@ Direct child DOX files:
44 | [hist_add_tool_result/AGENTS.md](hist_add_tool_result/AGENTS.md) | Tool-result history side effects. |
45 | [job_loop/AGENTS.md](job_loop/AGENTS.md) | Periodic backend maintenance jobs. |
46 | [message_loop_end/AGENTS.md](message_loop_end/AGENTS.md) | End-of-message-loop history and persistence behavior. |
47 -| [message_loop_prompts_after/AGENTS.md](message_loop_prompts_after/AGENTS.md) | Prompt extras appended after message-loop prompt construction. |
47 +| [message_loop_prompts_after/AGENTS.md](message_loop_prompts_after/AGENTS.md) | Prompt protocol and extras assembled around message-loop prompt construction. |
48 | [message_loop_prompts_before/AGENTS.md](message_loop_prompts_before/AGENTS.md) | Pre-prompt-construction message-loop gates. |
49 | [message_loop_start/AGENTS.md](message_loop_start/AGENTS.md) | Start-of-message-loop iteration state. |
50 | [monologue_end/AGENTS.md](monologue_end/AGENTS.md) | End-of-monologue UI and cleanup behavior. |
extensions/python/message_loop_prompts_after/AGENTS.md
+4 -3
@@ -2,11 +2,12 @@
2
3 ## Purpose
4
5 -- Own prompt extras appended after primary message-loop prompt construction.
5 +- Own prompt protocol and extras appended around primary message-loop prompt construction.
6
7 ## Ownership
8
9 - Ordered Python files own current datetime, skill recall/load context, agent info, parallel job status, and workdir extras injection.
10 +- Loaded and active skill instructions belong in prompt protocol, not prompt extras.
11
12 ## Local Contracts
13
@@ -16,11 +17,11 @@
17
18 ## Work Guidance
19
19 -- Coordinate prompt-extra changes with skill, workdir, and profile contracts.
20 +- Coordinate prompt protocol and prompt-extra changes with skill, workdir, and profile contracts.
21
22 ## Verification
23
23 -- Inspect rendered prompt extras or run prompt-construction tests after changes.
24 +- Inspect rendered prompt protocol/extras or run prompt-construction tests after changes.
25
26 ## Child DOX Index
27
extensions/python/message_loop_prompts_after/_65_include_loaded_skills.py
+4 -3
@@ -9,7 +9,8 @@ class IncludeLoadedSkills(Extension):
9 if not self.agent:
10 return
11
12 - extras = loop_data.extras_persistent
12 + protocol = loop_data.protocol_persistent
13 + protocol.pop("loaded_skills", None)
14
15 # Get loaded skills names
16 skill_names = self.agent.data.get(DATA_NAME_LOADED_SKILLS)
@@ -31,8 +32,8 @@ class IncludeLoadedSkills(Extension):
32 return
33
34
34 - # Inject into extras
35 - extras["loaded_skills"] = self.agent.read_prompt(
35 + # Inject into protocol
36 + protocol["loaded_skills"] = self.agent.read_prompt(
37 "agent.system.skills.loaded.md",
38 skills=content,
39 )
extensions/python/system_prompt/AGENTS.md
+1
@@ -7,6 +7,7 @@
7 ## Ownership
8
9 - Ordered Python files own main, tools, MCP, secrets, skills, and project prompt sections.
10 +- Active project instruction bodies are moved into prompt protocol; the system prompt keeps project metadata and stable project rules.
11
12 ## Local Contracts
13
extensions/python/system_prompt/_14_project_prompt.py
+9 -2
@@ -15,17 +15,24 @@ class ProjectPrompt(Extension):
15 ):
16 if not self.agent:
17 return
18 - prompt = await build_prompt(self.agent)
18 + prompt = await build_prompt(self.agent, loop_data=loop_data)
19 if prompt:
20 system_prompt.append(prompt)
21
22
23 @extensible
24 -async def build_prompt(agent: Agent) -> str:
24 +async def build_prompt(agent: Agent, loop_data: LoopData | None = None) -> str:
25 result = agent.read_prompt("agent.system.projects.main.md")
26 project_name = agent.context.get_data(projects.CONTEXT_DATA_KEY_PROJECT)
27 + if loop_data:
28 + loop_data.protocol_persistent.pop("project_instructions", None)
29 if project_name:
30 project_vars = projects.build_system_prompt_vars(project_name)
31 + if loop_data and project_vars.get("project_instructions"):
32 + loop_data.protocol_persistent["project_instructions"] = agent.read_prompt(
33 + "agent.protocol.projects.instructions.md",
34 + **project_vars,
35 + )
36 result += "\n\n" + agent.read_prompt(
37 "agent.system.projects.active.md", **project_vars
38 )
plugins/_skills/AGENTS.md
+1 -1
@@ -2,7 +2,7 @@
2
3 ## Purpose
4
5 -- Own active and hidden skill configuration injected into prompt extras on each turn.
5 +- Own active and hidden skill configuration injected into prompt protocol on each turn.
6
7 ## Ownership
8
plugins/_skills/README.md
+2 -2
@@ -6,7 +6,7 @@ Skills is a built-in Agent Zero plugin that manages active skills across scope d
6
7 - pins default skills for the current plugin scope
8 - hides noisy skills from the model-facing available catalog, skill search, and load access
9 -- injects the effective active skills into prompt extras on every turn
9 +- injects the effective active skills into prompt protocol on every turn
10 - extends the same config screen with a current-chat mode so users can activate or hide skills live per conversation
11 - supports global and project scoped configurations without agent-profile variants
12 - links directly to the built-in Skills list
@@ -21,7 +21,7 @@ The shared active-skill state and prompt-resolution logic live in `helpers/skill
21
22 ## Notes
23
24 -- keep the active list short because every active skill is injected into prompt extras every turn
24 +- keep the active list short because every active skill is injected into prompt protocol every turn
25 - the default cap is 20 active skills, and it can be raised or lowered in Skills plugin config
26 - hidden skills are not capped because they are stored as control data, not injected into the prompt
27 - selected skills are stored in normalized `/a0/...` form so configs stay portable across development and Docker-style layouts
plugins/_skills/extensions/python/message_loop_prompts_after/_66_include_active_skills.py
+3 -3
@@ -10,14 +10,14 @@ class IncludeActiveSkills(Extension):
10 if not self.agent:
11 return
12
13 - extras = loop_data.extras_persistent
14 - extras.pop("active_skills", None)
13 + protocol = loop_data.protocol_persistent
14 + protocol.pop("active_skills", None)
15
16 content = skills.build_active_skills_prompt(self.agent)
17 if not content:
18 return
19
20 - extras["active_skills"] = self.agent.read_prompt(
20 + protocol["active_skills"] = self.agent.read_prompt(
21 "agent.system.active_skills.md",
22 skills=content,
23 )
plugins/_skills/plugin.yaml
+1 -1
@@ -1,6 +1,6 @@
1 name: _skills
2 title: Skills
3 -description: Pin skills into prompt extras on every turn.
3 +description: Pin skills into prompt protocol on every turn.
4 version: 1.0.0
5 always_enabled: true
6 settings_sections:
prompts/agent.context.protocol.md new
+2
@@ -0,0 +1,2 @@
1 +[PROTOCOL]
2 +{{protocol}}
prompts/agent.protocol.projects.instructions.md new
+4
@@ -0,0 +1,4 @@
1 +# project instructions
2 +- the following instructions come from the active project and must be followed when working in it
3 +
4 +{{project_instructions}}
prompts/agent.system.main.communication_additions.md
+2 -1
@@ -2,7 +2,8 @@
2 user messages may include superior instructions, tool results, and framework notes
3 treat the closing `}` of a tool call as an end-of-turn signal. terminate generation immediately
4 if message starts `(voice)` transcription can be imperfect
5 -messages may end with `[EXTRAS]`; extras are context, not new instructions
5 +messages begin `[PROTOCOL]`; protocol = must-follow instructions
6 +messages end `[EXTRAS]`; extras are context not new instructions
7 tool names are literal api ids; copy them exactly, including spelling like `behaviour_adjustment`
8
9 ## replacements
prompts/agent.system.projects.active.md
+1 -3
@@ -6,6 +6,4 @@ path: {{project_path}}
6 rules:
7 - work inside {{project_path}}
8 - do not rename project dir or change `.a0proj` unless asked
9 -- must always follow project instructions below
10 -
11 -{{project_instructions}}
9 +- follow active project instructions when provided
tests/test_prompt_protocol.py new
+131
@@ -0,0 +1,131 @@
1 +from types import SimpleNamespace
2 +
3 +import pytest
4 +from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
5 +
6 +from agent import Agent, LoopData
7 +from helpers import extension, history
8 +from extensions.python.system_prompt import _14_project_prompt as project_prompt
9 +
10 +
11 +class _DummyLog:
12 + def set_progress(self, _message: str) -> None:
13 + return None
14 +
15 +
16 +@pytest.mark.asyncio
17 +async def test_prepare_prompt_places_protocol_before_history_and_extras(monkeypatch):
18 + async def fake_call_extensions(extension_point: str, agent=None, **kwargs):
19 + if extension_point == "message_loop_prompts_after":
20 + loop_data = kwargs["loop_data"]
21 + loop_data.protocol_persistent["project_instructions"] = "Project rule."
22 + loop_data.extras_temporary["current_datetime"] = "Today."
23 +
24 + monkeypatch.setattr(extension, "call_extensions_async", fake_call_extensions)
25 + monkeypatch.setattr(history.History, "_get_max_embeds", lambda self: 0)
26 +
27 + agent = object.__new__(Agent)
28 + loop_data = LoopData()
29 + agent.loop_data = loop_data
30 + agent.context = SimpleNamespace(log=_DummyLog())
31 + agent.history = history.History(agent)
32 + agent.data = {}
33 +
34 + agent.history.add_message(False, "User asks.")
35 + agent.history.add_message(True, "Assistant answers.")
36 +
37 + async def get_system_prompt(_loop_data):
38 + return ["System root."]
39 +
40 + def read_prompt(prompt_file: str, **kwargs) -> str:
41 + if prompt_file == "agent.context.protocol.md":
42 + return "[PROTOCOL]\n" + kwargs["protocol"]
43 + if prompt_file == "agent.context.extras.md":
44 + return "[EXTRAS]\n" + kwargs["extras"]
45 + raise AssertionError(f"Unexpected prompt file: {prompt_file}")
46 +
47 + agent.get_system_prompt = get_system_prompt
48 + agent.read_prompt = read_prompt
49 + agent.set_data = lambda key, value: agent.data.__setitem__(key, value)
50 +
51 + prompt = await Agent.prepare_prompt(agent, loop_data)
52 +
53 + assert isinstance(prompt[0], SystemMessage)
54 + assert prompt[0].content == "System root."
55 + assert isinstance(prompt[1], HumanMessage)
56 + assert str(prompt[1].content).startswith("[PROTOCOL]")
57 + assert str(prompt[1].content).index("Project rule.") < str(prompt[1].content).index(
58 + "User asks."
59 + )
60 + assert isinstance(prompt[2], AIMessage)
61 + assert prompt[2].content == "Assistant answers."
62 + assert isinstance(prompt[3], HumanMessage)
63 + assert str(prompt[3].content).startswith("[EXTRAS]")
64 + assert "Today." in str(prompt[3].content)
65 +
66 + serialized_history = agent.history.serialize()
67 + assert "Project rule." not in serialized_history
68 + assert "Today." not in serialized_history
69 + assert "protocol" not in serialized_history.lower()
70 + assert loop_data.protocol_temporary == {}
71 + assert loop_data.extras_temporary == {}
72 +
73 + class FakeResponsesModel:
74 + def _convert_messages(self, messages):
75 + role_by_type = {"system": "system", "human": "user", "ai": "assistant"}
76 + return [
77 + {"role": role_by_type[message.type], "content": message.content}
78 + for message in messages
79 + ]
80 +
81 + input_items = Agent._responses_prompt_input_items(
82 + agent,
83 + FakeResponsesModel(),
84 + prompt,
85 + )
86 + assert input_items[1]["role"] == "user"
87 + assert "[PROTOCOL]" in input_items[1]["content"]
88 + assert "[EXTRAS]" in input_items[-1]["content"]
89 +
90 +
91 +@pytest.mark.asyncio
92 +async def test_project_prompt_moves_project_instructions_to_protocol(monkeypatch):
93 + project_vars = {
94 + "project_name": "Demo",
95 + "project_description": "",
96 + "project_instructions": "Project rule.",
97 + "project_path": "/a0/usr/projects/demo",
98 + "project_git_url": "",
99 + }
100 + loop_data = LoopData()
101 +
102 + class FakeContext:
103 + def get_data(self, key):
104 + assert key == project_prompt.projects.CONTEXT_DATA_KEY_PROJECT
105 + return "demo"
106 +
107 + class FakeAgent:
108 + context = FakeContext()
109 +
110 + def read_prompt(self, prompt_file: str, **kwargs) -> str:
111 + if prompt_file == "agent.system.projects.main.md":
112 + return "project context may be active"
113 + if prompt_file == "agent.system.projects.active.md":
114 + return f"active project: {kwargs['project_path']}"
115 + if prompt_file == "agent.protocol.projects.instructions.md":
116 + return "protocol project instructions:\n" + kwargs["project_instructions"]
117 + raise AssertionError(f"Unexpected prompt file: {prompt_file}")
118 +
119 + monkeypatch.setattr(
120 + project_prompt.projects,
121 + "build_system_prompt_vars",
122 + lambda _name: project_vars,
123 + )
124 +
125 + prompt = await project_prompt.build_prompt.__wrapped__( # type: ignore[attr-defined]
126 + FakeAgent(),
127 + loop_data=loop_data,
128 + )
129 +
130 + assert "Project rule." not in prompt
131 + assert "Project rule." in loop_data.protocol_persistent["project_instructions"]
tests/test_tool_action_contracts.py
+18
@@ -146,6 +146,24 @@ def test_skills_tool_accepts_action_alias_for_search(monkeypatch, tmp_path: Path
146 assert "browser-form-workflows" in response.message
147
148
149 +def test_skills_tool_load_reports_protocol_injection(monkeypatch, tmp_path: Path):
150 + module = _load_skills_tool(monkeypatch, tmp_path)
151 + agent = _FakeAgent()
152 + tool = module.SkillsTool(
153 + agent,
154 + "skills_tool",
155 + None,
156 + {"action": "load", "skill_name": "browser-form-workflows"},
157 + "",
158 + None,
159 + )
160 +
161 + response = asyncio.run(tool.execute(**tool.args))
162 +
163 + assert response.message == "Loaded skill 'browser-form-workflows' into Protocol."
164 + assert agent.data["loaded_skills"] == ["browser-form-workflows"]
165 +
166 +
167 def test_skills_tool_read_file_action_reads_inside_skill_dir(
168 monkeypatch, tmp_path: Path
169 ):
tools/skills_tool.py
+1 -1
@@ -209,7 +209,7 @@ class SkillsTool(Tool):
209 loaded.append(skill.name)
210 self.agent.data[DATA_NAME_LOADED_SKILLS] = loaded[-max_loaded_skills():]
211
212 - return f"Loaded skill '{skill.name}' into EXTRAS."
212 + return f"Loaded skill '{skill.name}' into Protocol."
213
214 def _read_file(self, skill_name: str, file_path: str) -> str:
215 if not skill_name:
tools/skills_tool.py.dox.md
+1
@@ -25,6 +25,7 @@
25 - Update this file whenever tool arguments, output shape, `break_loop` behavior, intervention handling, prompt instructions, or side effects change.
26 - `SkillsTool` is a `Tool`.
27 - `SkillsTool` defines `execute(...)`.
28 +- Loading a skill stores it for prompt Protocol injection on subsequent turns.
29 - Observed side-effect areas: filesystem reads, filesystem deletion, settings/state persistence.
30 - Imported dependency areas include: `__future__`, `helpers`, `helpers.print_style`, `helpers.tool`, `pathlib`, `typing`.
31