Add protocol prompt area before history
Some checks are pending
Build And Publish Docker Images / plan (push) Waiting to run
Build And Publish Docker Images / build (push) Blocked by required conditions

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.
This commit is contained in:
frdel 2026-06-18 21:42:06 +02:00
parent bf2741990a
commit afdc3aeb44
24 changed files with 236 additions and 53 deletions

View file

@ -96,7 +96,7 @@ When running in Docker, Agent Zero uses two distinct Python runtimes to isolate
```
Key Files:
- agent.py: Defines AgentContext and the main Agent class.
- agent.py: Defines AgentContext, LoopData virtual prompt areas (Protocol before history and Extras after history), and the main Agent class.
- helpers/plugins.py: Plugin discovery and configuration logic.
- webui/js/AlpineStore.js: Store factory for reactive frontend state.
- helpers/api.py: Base class for all API endpoints.

View file

@ -338,6 +338,8 @@ class LoopData:
self.system = []
self.user_message: history.Message | None = None
self.history_output: list[history.OutputMessage] = []
self.protocol_temporary: OrderedDict[str, history.MessageContent] = OrderedDict()
self.protocol_persistent: OrderedDict[str, history.MessageContent] = OrderedDict()
self.extras_temporary: OrderedDict[str, history.MessageContent] = OrderedDict()
self.extras_persistent: OrderedDict[str, history.MessageContent] = OrderedDict()
self.last_response = ""
@ -580,24 +582,28 @@ class Agent:
# concatenate system prompt
system_text = "\n\n".join(loop_data.system)
# join extras
extras = history.Message( # type: ignore[abstract]
False,
content=self.read_prompt(
"agent.context.extras.md",
extras=dirty_json.stringify(
{**loop_data.extras_persistent, **loop_data.extras_temporary}
),
),
).output()
# join protocol and extras
protocol = self._build_context_message(
"agent.context.protocol.md",
"protocol",
{**loop_data.protocol_persistent, **loop_data.protocol_temporary},
include_empty=False,
)
extras = self._build_context_message(
"agent.context.extras.md",
"extras",
{**loop_data.extras_persistent, **loop_data.extras_temporary},
include_empty=True,
)
loop_data.protocol_temporary.clear()
loop_data.extras_temporary.clear()
# convert history + extras to LLM format
# convert protocol + history + extras to LLM format
history_langchain: list[BaseMessage] = history.output_langchain(
loop_data.history_output + extras
protocol + loop_data.history_output + extras
)
# build full prompt from system prompt, message history and extrS
# build full prompt from system prompt, protocol, message history and extras
full_prompt: list[BaseMessage] = [
SystemMessage(content=system_text),
*history_langchain,
@ -615,6 +621,24 @@ class Agent:
return full_prompt
def _build_context_message(
self,
prompt_file: str,
variable_name: str,
values: dict[str, history.MessageContent],
include_empty: bool,
) -> list[history.OutputMessage]:
if not include_empty and not values:
return []
return history.Message( # type: ignore[abstract]
False,
content=self.read_prompt(
prompt_file,
**{variable_name: dirty_json.stringify(values)},
),
).output()
@extension.extensible
async def handle_exception(self, location: str, exception: Exception):
if exception:
@ -920,9 +944,9 @@ class Agent:
model,
history_counter,
)
call_data["responses_local_input_items"] = (
self._responses_static_prefix_items(model, messages)
+ self._responses_input_items_since(model, 0)
call_data["responses_local_input_items"] = self._responses_prompt_input_items(
model,
messages,
)
await extension.call_extensions_async(
@ -1014,18 +1038,12 @@ class Agent:
return ResponsesTransport.input_from_messages(converted)
return []
def _responses_static_prefix_items(
def _responses_prompt_input_items(
self, model: Any, messages: list[BaseMessage]
) -> list[dict[str, Any]]:
prefix: list[BaseMessage] = []
for message in messages:
if isinstance(message, SystemMessage):
prefix.append(message)
continue
break
if not prefix or not hasattr(model, "_convert_messages"):
if not hasattr(model, "_convert_messages"):
return []
converted = model._convert_messages(prefix)
converted = model._convert_messages(messages)
return ResponsesTransport.input_from_messages(converted)
def _remember_llm_result_state(

View file

@ -27,7 +27,7 @@ docs focus on practical setup, screenshots, and user workflows.
- **[Desktop Guide](guides/desktop.md):** Use the built-in Linux desktop, GUI apps, and LibreOffice Writer/Calc/Impress Cowork.
- **[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.
- **[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.
- **[Skills Guide](guides/skills.md):** Open the Skills selector, add active skills, and remove prompt extras you no longer need.
- **[Skills Guide](guides/skills.md):** Open the Skills selector, add active skills, and remove prompt protocol entries you no longer need.
- **[Agent Profiles](guides/agent-profiles.md):** Switch the current chat profile or create a new guided profile from the chat input.
- **[Model Presets](guides/model-presets.md):** Create simple named shortcuts for model setups.
- **[Memory Guide](guides/memory.md):** Search, edit, delete, and curate memories so useful context does not become stale noise.

View file

@ -83,7 +83,7 @@ You can always rename them later.
| **Model Preset** | Which models power the chat. |
| **Agent Profile** | The agent's role, tone, and prompt behavior. |
| **Project** | Workspace, files, memory, secrets, and project instructions. |
| **Skill** | A specific procedure added to prompt extras. |
| **Skill** | A specific procedure added to prompt protocol. |
For example, you can use the same "Researcher" Agent Profile with a cheaper
preset for simple questions and a stronger preset for difficult investigations.

View file

@ -29,7 +29,7 @@ Click a skill to add it. Active skills are shown at the top of the selector.
To remove a skill, use the remove button in **Active skills** or uncheck it in
the list.
Active skills are added to the **Extras** part of the system prompt. That means
Active skills are added to the **Protocol** part of the prompt. That means
Agent Zero sees them every turn while they are active.
> [!TIP]
@ -56,7 +56,7 @@ usually easier for the agent to follow.
| Control | What it changes |
| --- | --- |
| **Skills** | Adds a specific procedure to the current prompt extras. |
| **Skills** | Adds a specific procedure to the current prompt protocol. |
| **Agent Profiles** | Changes the broader role and behavior of the chat. |
| **Projects** | Adds workspace, files, memory, secrets, and project instructions. |

View file

@ -74,7 +74,7 @@ Use the selector to add or remove active skills.
![Skills selector](../res/usage/webui/skills-selector-checked.png)
Active skills are added to the **Extras** part of the system prompt, so keep the
Active skills are added to the **Protocol** part of the prompt, so keep the
list short and intentional. See the [Skills guide](skills.md).
### Agent Profiles

View file

@ -141,7 +141,7 @@ Explains how to search, edit, delete, export, and curate memories before stale c
### [Open A0 Skills Guide](guides/skills.md)
Shows the chat input **+** menu, the Skills selector, and how active skills are added to prompt extras.
Shows the chat input **+** menu, the Skills selector, and how active skills are added to prompt protocol.
### [Open A0 Agent Profiles Guide](guides/agent-profiles.md)

View file

@ -44,7 +44,7 @@ Direct child DOX files:
| [hist_add_tool_result/AGENTS.md](hist_add_tool_result/AGENTS.md) | Tool-result history side effects. |
| [job_loop/AGENTS.md](job_loop/AGENTS.md) | Periodic backend maintenance jobs. |
| [message_loop_end/AGENTS.md](message_loop_end/AGENTS.md) | End-of-message-loop history and persistence behavior. |
| [message_loop_prompts_after/AGENTS.md](message_loop_prompts_after/AGENTS.md) | Prompt extras appended after message-loop prompt construction. |
| [message_loop_prompts_after/AGENTS.md](message_loop_prompts_after/AGENTS.md) | Prompt protocol and extras assembled around message-loop prompt construction. |
| [message_loop_prompts_before/AGENTS.md](message_loop_prompts_before/AGENTS.md) | Pre-prompt-construction message-loop gates. |
| [message_loop_start/AGENTS.md](message_loop_start/AGENTS.md) | Start-of-message-loop iteration state. |
| [monologue_end/AGENTS.md](monologue_end/AGENTS.md) | End-of-monologue UI and cleanup behavior. |

View file

@ -2,11 +2,12 @@
## Purpose
- Own prompt extras appended after primary message-loop prompt construction.
- Own prompt protocol and extras appended around primary message-loop prompt construction.
## Ownership
- Ordered Python files own current datetime, skill recall/load context, agent info, parallel job status, and workdir extras injection.
- Loaded and active skill instructions belong in prompt protocol, not prompt extras.
## Local Contracts
@ -16,11 +17,11 @@
## Work Guidance
- Coordinate prompt-extra changes with skill, workdir, and profile contracts.
- Coordinate prompt protocol and prompt-extra changes with skill, workdir, and profile contracts.
## Verification
- Inspect rendered prompt extras or run prompt-construction tests after changes.
- Inspect rendered prompt protocol/extras or run prompt-construction tests after changes.
## Child DOX Index

View file

@ -9,7 +9,8 @@ class IncludeLoadedSkills(Extension):
if not self.agent:
return
extras = loop_data.extras_persistent
protocol = loop_data.protocol_persistent
protocol.pop("loaded_skills", None)
# Get loaded skills names
skill_names = self.agent.data.get(DATA_NAME_LOADED_SKILLS)
@ -31,8 +32,8 @@ class IncludeLoadedSkills(Extension):
return
# Inject into extras
extras["loaded_skills"] = self.agent.read_prompt(
# Inject into protocol
protocol["loaded_skills"] = self.agent.read_prompt(
"agent.system.skills.loaded.md",
skills=content,
)

View file

@ -7,6 +7,7 @@
## Ownership
- Ordered Python files own main, tools, MCP, secrets, skills, and project prompt sections.
- Active project instruction bodies are moved into prompt protocol; the system prompt keeps project metadata and stable project rules.
## Local Contracts

View file

@ -15,17 +15,24 @@ class ProjectPrompt(Extension):
):
if not self.agent:
return
prompt = await build_prompt(self.agent)
prompt = await build_prompt(self.agent, loop_data=loop_data)
if prompt:
system_prompt.append(prompt)
@extensible
async def build_prompt(agent: Agent) -> str:
async def build_prompt(agent: Agent, loop_data: LoopData | None = None) -> str:
result = agent.read_prompt("agent.system.projects.main.md")
project_name = agent.context.get_data(projects.CONTEXT_DATA_KEY_PROJECT)
if loop_data:
loop_data.protocol_persistent.pop("project_instructions", None)
if project_name:
project_vars = projects.build_system_prompt_vars(project_name)
if loop_data and project_vars.get("project_instructions"):
loop_data.protocol_persistent["project_instructions"] = agent.read_prompt(
"agent.protocol.projects.instructions.md",
**project_vars,
)
result += "\n\n" + agent.read_prompt(
"agent.system.projects.active.md", **project_vars
)

View file

@ -2,7 +2,7 @@
## Purpose
- Own active and hidden skill configuration injected into prompt extras on each turn.
- Own active and hidden skill configuration injected into prompt protocol on each turn.
## Ownership

View file

@ -6,7 +6,7 @@ Skills is a built-in Agent Zero plugin that manages active skills across scope d
- pins default skills for the current plugin scope
- hides noisy skills from the model-facing available catalog, skill search, and load access
- injects the effective active skills into prompt extras on every turn
- injects the effective active skills into prompt protocol on every turn
- extends the same config screen with a current-chat mode so users can activate or hide skills live per conversation
- supports global and project scoped configurations without agent-profile variants
- 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
## Notes
- keep the active list short because every active skill is injected into prompt extras every turn
- keep the active list short because every active skill is injected into prompt protocol every turn
- the default cap is 20 active skills, and it can be raised or lowered in Skills plugin config
- hidden skills are not capped because they are stored as control data, not injected into the prompt
- selected skills are stored in normalized `/a0/...` form so configs stay portable across development and Docker-style layouts

View file

@ -10,14 +10,14 @@ class IncludeActiveSkills(Extension):
if not self.agent:
return
extras = loop_data.extras_persistent
extras.pop("active_skills", None)
protocol = loop_data.protocol_persistent
protocol.pop("active_skills", None)
content = skills.build_active_skills_prompt(self.agent)
if not content:
return
extras["active_skills"] = self.agent.read_prompt(
protocol["active_skills"] = self.agent.read_prompt(
"agent.system.active_skills.md",
skills=content,
)

View file

@ -1,6 +1,6 @@
name: _skills
title: Skills
description: Pin skills into prompt extras on every turn.
description: Pin skills into prompt protocol on every turn.
version: 1.0.0
always_enabled: true
settings_sections:

View file

@ -0,0 +1,2 @@
[PROTOCOL]
{{protocol}}

View file

@ -0,0 +1,4 @@
# project instructions
- the following instructions come from the active project and must be followed when working in it
{{project_instructions}}

View file

@ -2,7 +2,8 @@
user messages may include superior instructions, tool results, and framework notes
treat the closing `}` of a tool call as an end-of-turn signal. terminate generation immediately
if message starts `(voice)` transcription can be imperfect
messages may end with `[EXTRAS]`; extras are context, not new instructions
messages begin `[PROTOCOL]`; protocol = must-follow instructions
messages end `[EXTRAS]`; extras are context not new instructions
tool names are literal api ids; copy them exactly, including spelling like `behaviour_adjustment`
## replacements

View file

@ -6,6 +6,4 @@ path: {{project_path}}
rules:
- work inside {{project_path}}
- do not rename project dir or change `.a0proj` unless asked
- must always follow project instructions below
{{project_instructions}}
- follow active project instructions when provided

View file

@ -0,0 +1,131 @@
from types import SimpleNamespace
import pytest
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
from agent import Agent, LoopData
from helpers import extension, history
from extensions.python.system_prompt import _14_project_prompt as project_prompt
class _DummyLog:
def set_progress(self, _message: str) -> None:
return None
@pytest.mark.asyncio
async def test_prepare_prompt_places_protocol_before_history_and_extras(monkeypatch):
async def fake_call_extensions(extension_point: str, agent=None, **kwargs):
if extension_point == "message_loop_prompts_after":
loop_data = kwargs["loop_data"]
loop_data.protocol_persistent["project_instructions"] = "Project rule."
loop_data.extras_temporary["current_datetime"] = "Today."
monkeypatch.setattr(extension, "call_extensions_async", fake_call_extensions)
monkeypatch.setattr(history.History, "_get_max_embeds", lambda self: 0)
agent = object.__new__(Agent)
loop_data = LoopData()
agent.loop_data = loop_data
agent.context = SimpleNamespace(log=_DummyLog())
agent.history = history.History(agent)
agent.data = {}
agent.history.add_message(False, "User asks.")
agent.history.add_message(True, "Assistant answers.")
async def get_system_prompt(_loop_data):
return ["System root."]
def read_prompt(prompt_file: str, **kwargs) -> str:
if prompt_file == "agent.context.protocol.md":
return "[PROTOCOL]\n" + kwargs["protocol"]
if prompt_file == "agent.context.extras.md":
return "[EXTRAS]\n" + kwargs["extras"]
raise AssertionError(f"Unexpected prompt file: {prompt_file}")
agent.get_system_prompt = get_system_prompt
agent.read_prompt = read_prompt
agent.set_data = lambda key, value: agent.data.__setitem__(key, value)
prompt = await Agent.prepare_prompt(agent, loop_data)
assert isinstance(prompt[0], SystemMessage)
assert prompt[0].content == "System root."
assert isinstance(prompt[1], HumanMessage)
assert str(prompt[1].content).startswith("[PROTOCOL]")
assert str(prompt[1].content).index("Project rule.") < str(prompt[1].content).index(
"User asks."
)
assert isinstance(prompt[2], AIMessage)
assert prompt[2].content == "Assistant answers."
assert isinstance(prompt[3], HumanMessage)
assert str(prompt[3].content).startswith("[EXTRAS]")
assert "Today." in str(prompt[3].content)
serialized_history = agent.history.serialize()
assert "Project rule." not in serialized_history
assert "Today." not in serialized_history
assert "protocol" not in serialized_history.lower()
assert loop_data.protocol_temporary == {}
assert loop_data.extras_temporary == {}
class FakeResponsesModel:
def _convert_messages(self, messages):
role_by_type = {"system": "system", "human": "user", "ai": "assistant"}
return [
{"role": role_by_type[message.type], "content": message.content}
for message in messages
]
input_items = Agent._responses_prompt_input_items(
agent,
FakeResponsesModel(),
prompt,
)
assert input_items[1]["role"] == "user"
assert "[PROTOCOL]" in input_items[1]["content"]
assert "[EXTRAS]" in input_items[-1]["content"]
@pytest.mark.asyncio
async def test_project_prompt_moves_project_instructions_to_protocol(monkeypatch):
project_vars = {
"project_name": "Demo",
"project_description": "",
"project_instructions": "Project rule.",
"project_path": "/a0/usr/projects/demo",
"project_git_url": "",
}
loop_data = LoopData()
class FakeContext:
def get_data(self, key):
assert key == project_prompt.projects.CONTEXT_DATA_KEY_PROJECT
return "demo"
class FakeAgent:
context = FakeContext()
def read_prompt(self, prompt_file: str, **kwargs) -> str:
if prompt_file == "agent.system.projects.main.md":
return "project context may be active"
if prompt_file == "agent.system.projects.active.md":
return f"active project: {kwargs['project_path']}"
if prompt_file == "agent.protocol.projects.instructions.md":
return "protocol project instructions:\n" + kwargs["project_instructions"]
raise AssertionError(f"Unexpected prompt file: {prompt_file}")
monkeypatch.setattr(
project_prompt.projects,
"build_system_prompt_vars",
lambda _name: project_vars,
)
prompt = await project_prompt.build_prompt.__wrapped__( # type: ignore[attr-defined]
FakeAgent(),
loop_data=loop_data,
)
assert "Project rule." not in prompt
assert "Project rule." in loop_data.protocol_persistent["project_instructions"]

View file

@ -146,6 +146,24 @@ def test_skills_tool_accepts_action_alias_for_search(monkeypatch, tmp_path: Path
assert "browser-form-workflows" in response.message
def test_skills_tool_load_reports_protocol_injection(monkeypatch, tmp_path: Path):
module = _load_skills_tool(monkeypatch, tmp_path)
agent = _FakeAgent()
tool = module.SkillsTool(
agent,
"skills_tool",
None,
{"action": "load", "skill_name": "browser-form-workflows"},
"",
None,
)
response = asyncio.run(tool.execute(**tool.args))
assert response.message == "Loaded skill 'browser-form-workflows' into Protocol."
assert agent.data["loaded_skills"] == ["browser-form-workflows"]
def test_skills_tool_read_file_action_reads_inside_skill_dir(
monkeypatch, tmp_path: Path
):

View file

@ -209,7 +209,7 @@ class SkillsTool(Tool):
loaded.append(skill.name)
self.agent.data[DATA_NAME_LOADED_SKILLS] = loaded[-max_loaded_skills():]
return f"Loaded skill '{skill.name}' into EXTRAS."
return f"Loaded skill '{skill.name}' into Protocol."
def _read_file(self, skill_name: str, file_path: str) -> str:
if not skill_name:

View file

@ -25,6 +25,7 @@
- Update this file whenever tool arguments, output shape, `break_loop` behavior, intervention handling, prompt instructions, or side effects change.
- `SkillsTool` is a `Tool`.
- `SkillsTool` defines `execute(...)`.
- Loading a skill stores it for prompt Protocol injection on subsequent turns.
- Observed side-effect areas: filesystem reads, filesystem deletion, settings/state persistence.
- Imported dependency areas include: `__future__`, `helpers`, `helpers.print_style`, `helpers.tool`, `pathlib`, `typing`.