feat(emma): bridge capability status into runtime

This commit is contained in:
Chris Christiansen 2026-09-18 15:58:04 +00:00
parent 4eacc756b0
commit 97421374c2
5 changed files with 280 additions and 3 deletions

View File

@ -0,0 +1,56 @@
"""
Builds a user-safe, read-only capability summary for the Emma system prompt.
"""
from typing import List, Dict
from capability_registry import Availability, Capability, list_capabilities
def build_capability_system_context() -> str:
"""
Builds a deterministic, user-safe, plain-text summary of capabilities.
The output is structured for inclusion in a system prompt and must not leak
internal details.
"""
categorized: Dict[Availability, List[str]] = {
Availability.ACTIVE: [],
Availability.PLANNED: [],
Availability.FORBIDDEN: [],
}
for capability in list_capabilities():
if capability.availability == Availability.FORBIDDEN:
if capability.id == "terminal.arbitrary_shell":
categorized[Availability.FORBIDDEN].append(
"- Arbitrary terminal access is not available."
)
continue
if capability.availability in categorized:
categorized[capability.availability].append(
f"- {capability.display_name}: {capability.description}"
)
output_lines = ["CURRENT CAPABILITY STATUS FOR THIS CHAT"]
if categorized[Availability.ACTIVE]:
output_lines.append("\nAvailable now:")
output_lines.extend(categorized[Availability.ACTIVE])
if categorized[Availability.PLANNED]:
output_lines.append("\nPlanned, but not active in this chat:")
output_lines.extend(categorized[Availability.PLANNED])
if categorized[Availability.FORBIDDEN]:
output_lines.append("\nUnavailable:")
output_lines.extend(categorized[Availability.FORBIDDEN])
output_lines.append(
"\nRules:\n"
"- Planned capabilities are not available in this chat and must not be claimed as active.\n"
"- No tool, repository, deployment, memory, document retrieval, external service or state-changing action is activated by this capability summary.\n"
"- Future write or high-impact capabilities require a concrete, explicit, bound user approval before execution."
)
return "\n".join(output_lines)

View File

@ -22,7 +22,7 @@ class CanonicalEmma:
self._model = model self._model = model
self._system_prompt = system_prompt or get_runtime_system_prompt() self._system_prompt = system_prompt or get_runtime_system_prompt()
async def run(self, prompt, history=None): async def run(self, prompt, history=None, system_context: str | None = None):
""" """
Runs the Emma agent with the given prompt. Runs the Emma agent with the given prompt.
@ -30,15 +30,21 @@ class CanonicalEmma:
ensuring the canonical system prompt is used. History is ignored for now. ensuring the canonical system prompt is used. History is ignored for now.
Args: Args:
prompt: The user's prompt. prompt: The user\'s prompt.
history: The conversation history (currently ignored). history: The conversation history (currently ignored).
system_context: Optional context to append to the system prompt.
Returns: Returns:
The raw dictionary response from the chat_function. The raw dictionary response from the chat_function.
""" """
# Phase 1 does not use history or memory. # Phase 1 does not use history or memory.
composed_system_prompt = self._system_prompt
if system_context:
composed_system_prompt = f"{self._system_prompt}\n\n{system_context.strip()}"
return await self._chat_function( return await self._chat_function(
self._model, self._model,
prompt, prompt,
self._system_prompt, composed_system_prompt,
) )

View File

@ -58,6 +58,7 @@ except Exception as e:
print(f"Failed to load provision_new_mcp_module: {e}") print(f"Failed to load provision_new_mcp_module: {e}")
provision_new_mcp_module = None provision_new_mcp_module = None
from emma_adapter import CanonicalEmma from emma_adapter import CanonicalEmma
from capability_bridge import build_capability_system_context
from email.mime.text import MIMEText from email.mime.text import MIMEText
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
from typing import Any, Optional, Dict, List from typing import Any, Optional, Dict, List
@ -874,9 +875,11 @@ async def run_jason(p):
return await _agent_post("/run", {"message": p.get("prompt", p.get("message", "")), "user_id": p.get("user_id", "opax"), "session_id": p.get("session_id", "mcp"), "mode": p.get("mode", "light")}) return await _agent_post("/run", {"message": p.get("prompt", p.get("message", "")), "user_id": p.get("user_id", "opax"), "session_id": p.get("session_id", "mcp"), "mode": p.get("mode", "light")})
async def run_emma(p: dict) -> dict: async def run_emma(p: dict) -> dict:
"""Kaller den kanoniske Emma-agenten med en prompt.""" """Kaller den kanoniske Emma-agenten med en prompt."""
capability_system_context = build_capability_system_context()
return await canonical_emma.run( return await canonical_emma.run(
prompt=p.get("prompt", p.get("message", "")), prompt=p.get("prompt", p.get("message", "")),
history=[], history=[],
system_context=capability_system_context,
) )
async def run_emma_fast(p): return await _ollama_chat(EMMA_FAST_MODEL, p.get("prompt", p.get("message", "")), p.get("system", "Du er en rask og konsis AI-assistent...")) async def run_emma_fast(p): return await _ollama_chat(EMMA_FAST_MODEL, p.get("prompt", p.get("message", "")), p.get("system", "Du er en rask og konsis AI-assistent..."))
async def run_qwen(p): return await _ollama_chat(EMMA_LIGHT_MODEL, p.get("prompt", p.get("message", ""))) async def run_qwen(p): return await _ollama_chat(EMMA_LIGHT_MODEL, p.get("prompt", p.get("message", "")))

View File

@ -0,0 +1,165 @@
import unittest
import ast
from pathlib import Path
import sys
# Ensure the project root is in the path for imports
REPO_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO_ROOT / "opax-mcp"))
from capability_bridge import build_capability_system_context
from capability_registry import (
list_capabilities,
Capability,
Availability,
)
class TestCapabilityBridge(unittest.TestCase):
def setUp(self):
self.context_output = build_capability_system_context()
def test_output_is_deterministic(self):
self.assertEqual(self.context_output, build_capability_system_context())
def test_output_has_all_three_headings(self):
self.assertIn("\nAvailable now:", self.context_output)
self.assertIn("\nPlanned, but not active in this chat:", self.context_output)
self.assertIn("\nUnavailable:", self.context_output)
def test_available_section_contains_correct_capabilities(self):
active_capabilities = [
c for c in list_capabilities() if c.availability == Availability.ACTIVE
]
self.assertIn("- Emma chat: Conversational reasoning through Emma.", self.context_output)
self.assertIn("- Analyze user-provided text: Analyze, summarize, and structure text provided in the current request.", self.context_output)
# Isolate the "Available now" section to count items accurately
start_index = self.context_output.find("Available now:")
end_index = self.context_output.find("\n\nPlanned, but not active in this chat:")
available_section = self.context_output[start_index:end_index]
self.assertEqual(available_section.count("- "), len(active_capabilities))
def test_planned_section_contains_all_planned_capabilities(self):
planned_capabilities = [
c for c in list_capabilities() if c.availability == Availability.PLANNED
]
self.assertEqual(len(planned_capabilities), 16)
for capability in planned_capabilities:
self.assertIn(f"- {capability.display_name}: {capability.description}", self.context_output)
def test_unavailable_section_is_correct(self):
self.assertIn("- Arbitrary terminal access is not available.", self.context_output)
# Ensure no other forbidden capabilities are listed by name
self.assertNotIn("Arbitrary terminal shell:", self.context_output)
def test_bridge_text_does_not_contain_forbidden_internal_details(self):
forbidden_substrings = [
# IDs
"emma.chat", "terminal.arbitrary_shell",
# Backend tool names
"run_emma", "list_commits", "get_file", "create_issue", "push_file",
"build_and_deploy_service",
# Internal metadata
"required_actor_scope", "approval_binding", "audit_required",
"rollback_required", "self_approval_forbidden",
# Target names / infrastructure
"git.vauco.no", "opax-mcp", "propane-will-491900-m5", "us-central1",
]
for substring in forbidden_substrings:
self.assertNotIn(substring, self.context_output)
def test_bridge_module_has_no_prohibited_imports(self):
bridge_path = REPO_ROOT / "opax-mcp" / "capability_bridge.py"
with open(bridge_path, "r", encoding="utf-8") as f:
tree = ast.parse(f.read())
forbidden_imports = {
"os", "subprocess", "requests", "httpx", "google", "gitea",
"firebase", "firestore",
}
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
self.assertNotIn(alias.name.split('.')[0], forbidden_imports)
elif isinstance(node, ast.ImportFrom):
if node.module:
self.assertNotIn(node.module.split('.')[0], forbidden_imports)
def test_bridge_does_not_mutate_registry(self):
# This is implicitly tested by the deterministic output and other tests,
# but a direct check confirms the source objects are not modified.
initial_capabilities = list_capabilities()
build_capability_system_context()
self.assertEqual(initial_capabilities, list_capabilities())
def test_bridge_output_includes_all_rules(self):
rules_block = (
"Rules:\n"
"- Planned capabilities are not available in this chat and must not be claimed as active.\n"
"- No tool, repository, deployment, memory, document retrieval, external service or state-changing action is activated by this capability summary.\n"
"- Future write or high-impact capabilities require a concrete, explicit, bound user approval before execution."
)
self.assertIn(rules_block, self.context_output)
def test_bridge_does_not_expose_backend_tool_names(self):
all_capabilities = list_capabilities()
tool_names = {c.backend_tool for c in all_capabilities if c.backend_tool}
for tool_name in tool_names:
self.assertNotIn(tool_name, self.context_output)
class TestServerIntegrationContract(unittest.TestCase):
def test_run_emma_integration_in_server_py(self):
server_path = REPO_ROOT / "opax-mcp" / "server.py"
with open(server_path, "r", encoding="utf-8") as f:
tree = ast.parse(f.read())
run_emma_func = None
for node in ast.walk(tree):
if isinstance(node, ast.AsyncFunctionDef) and node.name == "run_emma":
run_emma_func = node
break
self.assertIsNotNone(run_emma_func, "async def run_emma not found in server.py")
# 1. References build_capability_system_context
call_to_build_context = any(
isinstance(n, ast.Call) and isinstance(n.func, ast.Name) and n.func.id == 'build_capability_system_context'
for n in ast.walk(run_emma_func)
)
self.assertTrue(call_to_build_context, "run_emma does not call build_capability_system_context")
# 2. Passes system_context to canonical_emma.run
call_to_run = None
for node in ast.walk(run_emma_func):
if isinstance(node, ast.Call) and hasattr(node.func, 'value') and hasattr(node.func.value, 'id') and node.func.value.id == 'canonical_emma' and node.func.attr == 'run':
call_to_run = node
break
self.assertIsNotNone(call_to_run, "run_emma does not call canonical_emma.run")
has_system_context_kw = any(
kw.arg == 'system_context' for kw in call_to_run.keywords
)
self.assertTrue(has_system_context_kw, "system_context is not passed to canonical_emma.run")
# 3. Preserves history=[]
has_history_kw = any(
kw.arg == 'history' and isinstance(kw.value, ast.List) and not kw.value.elts
for kw in call_to_run.keywords
)
self.assertTrue(has_history_kw, "history=[] is not preserved in call to canonical_emma.run")
# 4. Preserves prompt argument structure
prompt_kw = next((kw for kw in call_to_run.keywords if kw.arg == 'prompt'), None)
self.assertIsNotNone(prompt_kw, "prompt argument missing in call to canonical_emma.run")
# A basic check to ensure it's calling p.get, not a simple variable
self.assertIsInstance(prompt_kw.value, ast.Call, "prompt argument is not a function call")
if __name__ == "__main__":
unittest.main()

View File

@ -134,5 +134,52 @@ class TestCanonicalEmmaPhase1B(unittest.IsolatedAsyncioTestCase):
) )
self.assertIn("Ingen handling utføres.", normalized_prompt) self.assertIn("Ingen handling utføres.", normalized_prompt)
async def test_run_without_system_context(self):
"""(New) Verifies run() without context passes the original system prompt."""
mock_chat_fn = AsyncMock()
model = "test-model-no-context"
prompt = "test prompt no context"
mock_response = {"response": "ok"}
mock_chat_fn.return_value = mock_response
adapter = CanonicalEmma(chat_function=mock_chat_fn, model=model)
result = await adapter.run(prompt, system_context=None)
self.assertEqual(result, mock_response)
mock_chat_fn.assert_awaited_once()
call_args = mock_chat_fn.call_args
self.assertEqual(len(call_args.args), 3)
self.assertEqual(call_args.args[0], model)
self.assertEqual(call_args.args[1], prompt)
self.assertEqual(call_args.args[2], get_runtime_system_prompt())
async def test_run_with_system_context(self):
"""(New) Verifies run() with context appends it correctly."""
mock_chat_fn = AsyncMock()
model = "test-model-with-context"
prompt = "test prompt with context"
bridge_context = "CURRENT CAPABILITY STATUS\n\nAvailable: Yes"
mock_response = {"response": "ok"}
mock_chat_fn.return_value = mock_response
adapter = CanonicalEmma(chat_function=mock_chat_fn, model=model)
result = await adapter.run(prompt, system_context=bridge_context)
self.assertEqual(result, mock_response)
mock_chat_fn.assert_awaited_once()
call_args = mock_chat_fn.call_args
self.assertEqual(len(call_args.args), 3)
self.assertEqual(call_args.args[0], model)
self.assertEqual(call_args.args[1], prompt) # User prompt is unchanged
composed_prompt = call_args.args[2]
canonical_prompt = get_runtime_system_prompt()
self.assertTrue(composed_prompt.startswith(canonical_prompt))
self.assertIn(f"\n\n{bridge_context.strip()}", composed_prompt)
if __name__ == '__main__': if __name__ == '__main__':
unittest.main() unittest.main()