feat(emma): bridge capability status into runtime
This commit is contained in:
parent
4eacc756b0
commit
97421374c2
56
opax-mcp/capability_bridge.py
Normal file
56
opax-mcp/capability_bridge.py
Normal 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)
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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", "")))
|
||||||
|
|
|
||||||
165
opax-mcp/test_capability_bridge.py
Normal file
165
opax-mcp/test_capability_bridge.py
Normal 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()
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user