diff --git a/demo/agent-server/src/agent_server/__main__.py b/demo/agent-server/src/agent_server/__main__.py index 20af00a..5bcb1b4 100644 --- a/demo/agent-server/src/agent_server/__main__.py +++ b/demo/agent-server/src/agent_server/__main__.py @@ -25,7 +25,7 @@ def main() -> None: s = get_settings() # uvicorn only configures its own loggers; add a root handler so the - # reference agents' logging (e.g. the assembled prompt in LLMAgent) shows. + # agents' logging (e.g. the HexGate policy decisions) shows. logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s") uvicorn.run(app, host=s.host, port=s.port) diff --git a/demo/agent-server/src/agent_server/actions.py b/demo/agent-server/src/agent_server/actions.py index edc7656..b371920 100644 --- a/demo/agent-server/src/agent_server/actions.py +++ b/demo/agent-server/src/agent_server/actions.py @@ -1,4 +1,4 @@ -"""Reference widget actions (CONTRACT.md §5b) for the Orbit + DevOps workspaces. +"""Reference widget actions (CONTRACT.md §5b) for the DevOps + ITSM workspaces. Backs the `data_source` + `action` + `refresh` mechanism: an action returns a single `{result}` and never pushes to the UI; display widgets re-pull their @@ -11,51 +11,10 @@ from __future__ import annotations -import csv -import io from typing import Any -from .agents import devops_state, itsm_db - -# The "retrieved sources" the Orbit `sources` table displays. -_SOURCES: list[dict[str, Any]] = [] - -_SEED: list[dict[str, Any]] = [ - {"title": "Retrieval-augmented generation: a survey", "url": "https://example.com/rag-survey", "score": 0.94}, - {"title": "Vector index benchmarks (2026)", "url": "https://example.com/vector-bench", "score": 0.91}, - {"title": "Chunking strategies that actually matter", "url": "https://example.com/chunking", "score": 0.88}, - {"title": "Evaluating grounded answers", "url": "https://example.com/grounded-eval", "score": 0.83}, - {"title": "Hybrid search: BM25 + embeddings", "url": "https://example.com/hybrid-search", "score": 0.79}, -] - - -def _sources_csv() -> str: - buf = io.StringIO() - writer = csv.writer(buf) - writer.writerow(["Title", "URL", "Score"]) - for s in _SOURCES: - writer.writerow([s["title"], s["url"], s["score"]]) - return buf.getvalue() - - -def _list_sources(_args: dict[str, Any]) -> dict[str, Any]: - """Data source for the `sources` table — return the rows as CSV.""" - return {"csv": _sources_csv()} - - -def _seed_sources(_args: dict[str, Any]) -> dict[str, Any]: - """Side-effect: populate a sample result set (a fake 'search').""" - _SOURCES.clear() - _SOURCES.extend(_SEED) - return {"count": len(_SOURCES)} - - -def _clear_sources(_args: dict[str, Any]) -> dict[str, Any]: - """Side-effect: drop all sources.""" - removed = len(_SOURCES) - _SOURCES.clear() - return {"removed": removed} - +from .agents.tech_org.devops import devops_state +from .agents.tech_org.itsm import itsm_db # ── DevOps service-state panel (CONTRACT §5b) ──────────────────────────────── # The env buttons call `select_env`; the two display widgets pull `service_summary` @@ -97,10 +56,6 @@ def _change_table(_args: dict[str, Any]) -> dict[str, Any]: _ACTIONS = { - # Orbit research workspace - "list_sources": _list_sources, - "seed_sources": _seed_sources, - "clear_sources": _clear_sources, # DevOps service-state panel "select_env": _select_env, "service_summary": _service_summary, diff --git a/demo/agent-server/src/agent_server/agents/adk_llm.py b/demo/agent-server/src/agent_server/agents/adk_llm.py deleted file mode 100644 index a341fd0..0000000 --- a/demo/agent-server/src/agent_server/agents/adk_llm.py +++ /dev/null @@ -1,104 +0,0 @@ -"""Optional Gemini-backed `google-adk` agent (Orbit). - -The google-adk analogue of `llm.py` (Probe/OpenAI): when ``AGENT_ENABLE_LLM`` is -set AND a ``GOOGLE_API_KEY`` is in the backend's environment, it streams a real -Gemini completion and **projects each chunk into google-adk-native ``Event`` -shapes**, so the proxy's google-adk translator normalizes it exactly as it would -a real ADK runtime. The selector (`agents.select`) returns this only under those -conditions; otherwise the deterministic `GoogleADKDemoAgent` (canned ADK events) -runs. - -On any failure (package missing, bad key, …) it degrades to a single visible -text event rather than crashing — same contract as `LLMAgent`. - -Needs the `google-genai` package in the run venv and ``GOOGLE_API_KEY`` set in -the backend's environment. -""" - -from __future__ import annotations - -import inspect -import logging -import os -from collections.abc import AsyncIterator -from typing import Any - -from .. import protocol - -logger = logging.getLogger("agent_server.adk") - -_MODEL = "gemini-2.5-flash" - - -def _build_prompt(input: dict[str, Any], files: list[dict], query: str) -> str: - """Flatten the transcript (+ any attached file text) into a single prompt. - - Mirrors `LLMAgent`'s inlining: attached files' decoded text leads, then the - conversation. Gemini accepts a plain string for `contents`.""" - messages = (input or {}).get("messages") or [{"role": "user", "content": query}] - parts: list[str] = [] - blocks = [ - f"## {f.get('name', 'file')}\n{f.get('content') or '[binary file omitted]'}" - for f in files - ] - if blocks: - parts.append("The user attached these files:\n\n" + "\n\n".join(blocks)) - for m in messages: - who = "User" if m.get("role") == "user" else "Assistant" - parts.append(f"{who}: {m.get('content', '')}") - parts.append("Assistant:") - return "\n\n".join(parts) - - -def _log_prompt(prompt: str) -> None: - logger.info("ADK/Gemini prompt:\n%s", prompt) - - -class GoogleADKAgent: - """Streams a real Gemini completion as google-adk `Event` projections.""" - - framework = "google-adk" - - async def run( - self, - *, - input: dict[str, Any], - context: dict[str, Any], - ) -> AsyncIterator[dict]: - query = protocol.last_user_text(input) - api_key = os.getenv("GOOGLE_API_KEY") - files = (context or {}).get("files") or [] - author = "assistant" - - try: - from google import genai - - client = genai.Client(api_key=api_key) - prompt = _build_prompt(input, files, query) - _log_prompt(prompt) - - # The async streaming entrypoint returns either an async iterator or - # a coroutine resolving to one, depending on the SDK version — handle - # both so we don't break on a minor API shift. - maybe = client.aio.models.generate_content_stream( - model=_MODEL, contents=prompt - ) - stream = await maybe if inspect.isawaitable(maybe) else maybe - - async for chunk in stream: - text = getattr(chunk, "text", None) - if text: - yield { - "author": author, - "partial": True, - "content": {"parts": [{"text": text}]}, - } - # Close the streamed text block for the translator. - yield {"author": author, "turn_complete": True, "content": {"parts": []}} - except Exception as e: # noqa: BLE001 — degrade to a visible text event - yield { - "author": author, - "partial": True, - "content": {"parts": [{"text": f"[gemini unavailable: {e}] echo: {query}"}]}, - } - yield {"author": author, "turn_complete": True, "content": {"parts": []}} diff --git a/demo/agent-server/src/agent_server/agents/clinic_org/__init__.py b/demo/agent-server/src/agent_server/agents/clinic_org/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/demo/agent-server/src/agent_server/agents/clinic_org/healthcare/__init__.py b/demo/agent-server/src/agent_server/agents/clinic_org/healthcare/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/demo/agent-server/src/agent_server/agents/healthcare.py b/demo/agent-server/src/agent_server/agents/clinic_org/healthcare/healthcare.py similarity index 95% rename from demo/agent-server/src/agent_server/agents/healthcare.py rename to demo/agent-server/src/agent_server/agents/clinic_org/healthcare/healthcare.py index 65ddb9f..e797473 100644 --- a/demo/agent-server/src/agent_server/agents/healthcare.py +++ b/demo/agent-server/src/agent_server/agents/clinic_org/healthcare/healthcare.py @@ -14,9 +14,10 @@ from agents import set_default_openai_key -from .. import protocol +from agent_server import protocol +from agent_server.frameworks.openai_agents import agent_input, to_native_event + from . import healthcare_agent -from .openai_agents import agent_input, to_native_event logger = logging.getLogger("agent_server.healthcare") diff --git a/demo/agent-server/src/agent_server/agents/healthcare_agent.py b/demo/agent-server/src/agent_server/agents/clinic_org/healthcare/healthcare_agent.py similarity index 100% rename from demo/agent-server/src/agent_server/agents/healthcare_agent.py rename to demo/agent-server/src/agent_server/agents/clinic_org/healthcare/healthcare_agent.py diff --git a/demo/agent-server/src/agent_server/ui/healthcare.yaml b/demo/agent-server/src/agent_server/agents/clinic_org/healthcare/ui.yaml similarity index 100% rename from demo/agent-server/src/agent_server/ui/healthcare.yaml rename to demo/agent-server/src/agent_server/agents/clinic_org/healthcare/ui.yaml diff --git a/demo/agent-server/src/agent_server/agents/demos.py b/demo/agent-server/src/agent_server/agents/demos.py deleted file mode 100644 index fe1ddbf..0000000 --- a/demo/agent-server/src/agent_server/agents/demos.py +++ /dev/null @@ -1,131 +0,0 @@ -"""Reference agents that emit *framework-native* event shapes. - -These don't run real LangChain/OpenAI/ADK agents (that would need the libraries -+ live models). They emit the JSON projection of each framework's native event -stream — exactly what a developer's thin server layer forwards after running -their real agent — so the proxy's per-framework translators are exercised -end to end. Each yields a representative sequence: streamed text, then one tool -call + result. Per-chunk sleeps make cancellation observable. - -A real developer would write, e.g.:: - - async def run(self, *, input, context): - async for ev in my_langchain_runnable.astream_events(.., version="v2"): - yield hexa_forward(ev) # JSON-project the native event - -and the proxy does the rest. -""" - -from __future__ import annotations - -import asyncio -from collections.abc import AsyncIterator -from typing import Any - -from .. import protocol -from ..tools import fake_search - -_CHUNK_DELAY = 0.12 - - -class LangChainDemoAgent: - """Emits LangChain `astream_events(v2)` projections.""" - - framework = "langchain" - - async def run( - self, *, input: dict[str, Any], context: dict[str, Any] - ) -> AsyncIterator[dict]: - query = protocol.last_user_text(input) - rid = "model-1" - for word in f"LangChain echo: {query}".split(" "): - await asyncio.sleep(_CHUNK_DELAY) - yield { - "event": "on_chat_model_stream", - "run_id": rid, - "name": "chat", - "data": {"chunk": {"content": word + " "}}, - } - yield {"event": "on_chat_model_end", "run_id": rid, "data": {}} - - await asyncio.sleep(_CHUNK_DELAY) - yield { - "event": "on_tool_start", - "run_id": "tool-1", - "name": "search", - "data": {"input": {"q": query}}, - } - yield { - "event": "on_tool_end", - "run_id": "tool-1", - "name": "search", - "data": {"output": fake_search(query)}, - } - - -class OpenAIAgentsDemoAgent: - """Emits OpenAI Agents SDK `stream_events()` projections.""" - - framework = "openai-agents" - - async def run( - self, *, input: dict[str, Any], context: dict[str, Any] - ) -> AsyncIterator[dict]: - query = protocol.last_user_text(input) - text = f"OpenAI echo: {query}" - for word in text.split(" "): - await asyncio.sleep(_CHUNK_DELAY) - yield { - "type": "raw_response", - "data": {"type": "response.output_text.delta", "delta": word + " "}, - } - yield { - "type": "run_item", - "name": "message_output_created", - "item": {"raw_item": {"content": [{"text": text}]}}, - } - - await asyncio.sleep(_CHUNK_DELAY) - yield { - "type": "run_item", - "name": "tool_called", - "item": {"raw_item": {"call_id": "c1", "name": "search", - "arguments": f'{{"q": "{query}"}}'}}, - } - yield { - "type": "run_item", - "name": "tool_output", - "item": {"raw_item": {"call_id": "c1", "name": "search"}, - "output": fake_search(query)}, - } - - -class GoogleADKDemoAgent: - """Emits Google ADK `Event` projections.""" - - framework = "google-adk" - - async def run( - self, *, input: dict[str, Any], context: dict[str, Any] - ) -> AsyncIterator[dict]: - query = protocol.last_user_text(input) - author = "assistant" - for word in f"ADK echo: {query}".split(" "): - await asyncio.sleep(_CHUNK_DELAY) - yield { - "author": author, - "partial": True, - "content": {"parts": [{"text": word + " "}]}, - } - # function_call auto-closes the streamed text block in the emitter. - await asyncio.sleep(_CHUNK_DELAY) - yield { - "author": author, - "content": {"parts": [{"function_call": {"id": "c1", "name": "search", - "args": {"q": query}}}]}, - } - yield { - "author": author, - "content": {"parts": [{"function_response": {"id": "c1", "name": "search", - "response": fake_search(query)}}]}, - } diff --git a/demo/agent-server/src/agent_server/agents/echo.py b/demo/agent-server/src/agent_server/agents/echo.py deleted file mode 100644 index bb4f38b..0000000 --- a/demo/agent-server/src/agent_server/agents/echo.py +++ /dev/null @@ -1,53 +0,0 @@ -"""The reference echo agent — the deterministic, no-dependency baseline. - -Note how little it has to do: yield plain text chunks and a tool call. No run -ids, no sequence numbers, no block lifecycle — the proxy synthesizes all of -that. It word-chunks the last user message back, then fires one sample tool -call routed to the ``tool-calls`` widget. Per-chunk sleeps make cancellation -observable mid-stream. -""" - -from __future__ import annotations - -import asyncio -from collections.abc import AsyncIterator -from typing import Any - -from .. import protocol -from ..tools import fake_search - -_CHUNK_DELAY = 0.12 - - -class EchoAgent: - framework = "native" - - async def run( - self, - *, - input: dict[str, Any], - context: dict[str, Any], - ) -> AsyncIterator[dict]: - query = protocol.last_user_text(input) - - files = (context or {}).get("files") or [] - - # Echo a short content preview so it's visible the bytes actually arrived. - def _preview(f: dict) -> str: - c = (f.get("content") or "").strip().replace("\n", " ") - name = f.get("name", "?") - return f'{name}="{c[:60]}"' if c else f"{name}(binary)" - - files_note = ( - "files{ " + "; ".join(_preview(f) for f in files) + " } | " if files else "" - ) - reply = f"{files_note}echo: {query}".strip() - - for word in reply.split(" "): - await asyncio.sleep(_CHUNK_DELAY) - yield protocol.text(word + " ") - - # One sample tool call, routed to the `tool-calls` widget. - yield protocol.tool("t1", "search", {"q": query}, widget="tool-calls") - await asyncio.sleep(_CHUNK_DELAY) - yield protocol.tool_result("t1", output=fake_search(query)) diff --git a/demo/agent-server/src/agent_server/agents/llm.py b/demo/agent-server/src/agent_server/agents/llm.py deleted file mode 100644 index 9dad38e..0000000 --- a/demo/agent-server/src/agent_server/agents/llm.py +++ /dev/null @@ -1,80 +0,0 @@ -"""Optional OpenAI-backed `native` agent. - -Streams real model tokens as minimal ``text`` events (framework `native`). The -selector (`agents.select`) returns this only when ``AGENT_ENABLE_LLM=1`` and an -``OPENAI_API_KEY`` is set in the backend's environment; otherwise the -deterministic `EchoAgent` runs. Shows how a real agent reads its provider key -from its own env with minimal plumbing. -""" - -from __future__ import annotations - -import logging -import os -from collections.abc import AsyncIterator -from typing import Any - -from .. import protocol - -logger = logging.getLogger("agent_server.llm") - - -def _log_prompt(messages: list[dict]) -> None: - """Log the full prompt sent to the model — every message, untruncated, - including the system block with inlined file contents. Verbose by design: - it's the ground truth for "did my file's text actually reach the model?".""" - lines = [f"LLM prompt ({len(messages)} message(s)):"] - for m in messages: - lines.append(f" ── [{m.get('role')}] ──") - lines.append(str(m.get("content", ""))) - logger.info("\n".join(lines)) - - -class LLMAgent: - """Streams a real OpenAI completion as text events, falling back on failure.""" - - framework = "native" - - async def run( - self, - *, - input: dict[str, Any], - context: dict[str, Any], - ) -> AsyncIterator[dict]: - query = protocol.last_user_text(input) - api_key = os.getenv("OPENAI_API_KEY") - try: - from openai import AsyncOpenAI - - client = AsyncOpenAI(api_key=api_key) - messages = (input or {}).get("messages") or [ - {"role": "user", "content": query} - ] - # Attached files persist across the conversation — inline their text - # as a leading system message so the model can use them. - files = (context or {}).get("files") or [] - blocks = [ - f"## {f.get('name', 'file')}\n{f.get('content') or '[binary file omitted]'}" - for f in files - ] - if blocks: - messages = [ - { - "role": "system", - "content": "The user attached these files:\n\n" - + "\n\n".join(blocks), - }, - *messages, - ] - _log_prompt(messages) - stream = await client.chat.completions.create( - model="gpt-4o-mini", - messages=messages, - stream=True, - ) - async for chunk in stream: - delta = chunk.choices[0].delta.content if chunk.choices else None - if delta: - yield protocol.text(delta) - except Exception as e: # noqa: BLE001 — degrade to a visible text event - yield protocol.text(f"[llm unavailable: {e}] echo: {query}") diff --git a/demo/agent-server/src/agent_server/agents/select.py b/demo/agent-server/src/agent_server/agents/select.py deleted file mode 100644 index 4f9d254..0000000 --- a/demo/agent-server/src/agent_server/agents/select.py +++ /dev/null @@ -1,63 +0,0 @@ -"""Pick the agent implementation for a request. - -The roster declares each agent's `framework`; that drives which reference agent -runs (so the agent emits the matching framework's native events). The `native` -agent uses the deterministic echo, or the optional OpenAI-backed `LLMAgent` when -`AGENT_ENABLE_LLM=1` and `OPENAI_API_KEY` is set in the backend's environment. -""" - -from __future__ import annotations - -import os -from typing import Any - -from ..config import get_settings -from ..roster import get_agent -from .adk_llm import GoogleADKAgent -from .base import Agent -from .demos import GoogleADKDemoAgent, LangChainDemoAgent, OpenAIAgentsDemoAgent -from .echo import EchoAgent -from .llm import LLMAgent - - -def select_agent(agent_id: str, context: dict[str, Any]) -> Agent: - framework = (get_agent(agent_id) or {}).get("framework", "native") - llm_on = get_settings().enable_llm - - # Real OpenAI Agents SDK agent (it picks the plain vs HexGate path itself). - # Lazy import so a missing openai-agents/hexgate install doesn't break the roster. - if agent_id == "healthcare": - from .healthcare import HealthcareAgent - - return HealthcareAgent() - - if agent_id == "devops": - from .devops import DevopsAgent - - return DevopsAgent() - - if agent_id == "itsm": - from .itsm import ItsmAgent - - return ItsmAgent() - - if agent_id == "hr": - from .hr import HrAgent - - return HrAgent() - - if framework == "langchain": - return LangChainDemoAgent() - if framework == "openai-agents": - return OpenAIAgentsDemoAgent() - if framework == "google-adk": - # Real Gemini via Google ADK when enabled + GOOGLE_API_KEY is in the env; - # otherwise the deterministic canned ADK events. - if llm_on and os.getenv("GOOGLE_API_KEY"): - return GoogleADKAgent() - return GoogleADKDemoAgent() - - # native: deterministic echo, or real OpenAI when enabled + key in the env. - if llm_on and os.getenv("OPENAI_API_KEY"): - return LLMAgent() - return EchoAgent() diff --git a/demo/agent-server/src/agent_server/agents/shared/__init__.py b/demo/agent-server/src/agent_server/agents/shared/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/demo/agent-server/src/agent_server/agents/shared/hr/__init__.py b/demo/agent-server/src/agent_server/agents/shared/hr/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/demo/agent-server/src/agent_server/agents/hr.py b/demo/agent-server/src/agent_server/agents/shared/hr/hr.py similarity index 98% rename from demo/agent-server/src/agent_server/agents/hr.py rename to demo/agent-server/src/agent_server/agents/shared/hr/hr.py index da7d0cb..f00e380 100644 --- a/demo/agent-server/src/agent_server/agents/hr.py +++ b/demo/agent-server/src/agent_server/agents/shared/hr/hr.py @@ -12,7 +12,7 @@ from collections.abc import AsyncIterator from typing import Any -from .. import protocol +from agent_server import protocol logger = logging.getLogger("agent_server.hr") diff --git a/demo/agent-server/src/agent_server/agents/hr_agent.py b/demo/agent-server/src/agent_server/agents/shared/hr/hr_agent.py similarity index 99% rename from demo/agent-server/src/agent_server/agents/hr_agent.py rename to demo/agent-server/src/agent_server/agents/shared/hr/hr_agent.py index f1b4d67..59673da 100644 --- a/demo/agent-server/src/agent_server/agents/hr_agent.py +++ b/demo/agent-server/src/agent_server/agents/shared/hr/hr_agent.py @@ -32,8 +32,9 @@ from langchain_core.tools import tool from langchain_openai import ChatOpenAI +from agent_server.frameworks.langchain_events import messages_input, to_native_event # noqa: F401 + from . import hr_state -from .langchain_events import messages_input, to_native_event # noqa: F401 # Load .env at import — the eager `agent` below (which `hexgate register` # resolves) needs OPENAI_API_KEY at ChatOpenAI construction time. diff --git a/demo/agent-server/src/agent_server/agents/hr_state.py b/demo/agent-server/src/agent_server/agents/shared/hr/hr_state.py similarity index 100% rename from demo/agent-server/src/agent_server/agents/hr_state.py rename to demo/agent-server/src/agent_server/agents/shared/hr/hr_state.py diff --git a/demo/agent-server/src/agent_server/ui/hr.yaml b/demo/agent-server/src/agent_server/agents/shared/hr/ui.yaml similarity index 100% rename from demo/agent-server/src/agent_server/ui/hr.yaml rename to demo/agent-server/src/agent_server/agents/shared/hr/ui.yaml diff --git a/demo/agent-server/src/agent_server/agents/tech_org/__init__.py b/demo/agent-server/src/agent_server/agents/tech_org/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/demo/agent-server/src/agent_server/agents/tech_org/devops/__init__.py b/demo/agent-server/src/agent_server/agents/tech_org/devops/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/demo/agent-server/src/agent_server/agents/devops.py b/demo/agent-server/src/agent_server/agents/tech_org/devops/devops.py similarity index 96% rename from demo/agent-server/src/agent_server/agents/devops.py rename to demo/agent-server/src/agent_server/agents/tech_org/devops/devops.py index 542c6f8..c1a5c2c 100644 --- a/demo/agent-server/src/agent_server/agents/devops.py +++ b/demo/agent-server/src/agent_server/agents/tech_org/devops/devops.py @@ -13,9 +13,10 @@ from collections.abc import AsyncIterator from typing import Any -from .. import protocol +from agent_server import protocol +from agent_server.frameworks.google_adk import to_native_event + from . import devops_agent -from .google_adk import to_native_event logger = logging.getLogger("agent_server.devops") diff --git a/demo/agent-server/src/agent_server/agents/devops_agent.py b/demo/agent-server/src/agent_server/agents/tech_org/devops/devops_agent.py similarity index 100% rename from demo/agent-server/src/agent_server/agents/devops_agent.py rename to demo/agent-server/src/agent_server/agents/tech_org/devops/devops_agent.py diff --git a/demo/agent-server/src/agent_server/agents/devops_state.py b/demo/agent-server/src/agent_server/agents/tech_org/devops/devops_state.py similarity index 100% rename from demo/agent-server/src/agent_server/agents/devops_state.py rename to demo/agent-server/src/agent_server/agents/tech_org/devops/devops_state.py diff --git a/demo/agent-server/src/agent_server/ui/devops.yaml b/demo/agent-server/src/agent_server/agents/tech_org/devops/ui.yaml similarity index 100% rename from demo/agent-server/src/agent_server/ui/devops.yaml rename to demo/agent-server/src/agent_server/agents/tech_org/devops/ui.yaml diff --git a/demo/agent-server/src/agent_server/agents/tech_org/itsm/__init__.py b/demo/agent-server/src/agent_server/agents/tech_org/itsm/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/demo/agent-server/src/agent_server/agents/itsm.py b/demo/agent-server/src/agent_server/agents/tech_org/itsm/itsm.py similarity index 98% rename from demo/agent-server/src/agent_server/agents/itsm.py rename to demo/agent-server/src/agent_server/agents/tech_org/itsm/itsm.py index acdcdae..301f990 100644 --- a/demo/agent-server/src/agent_server/agents/itsm.py +++ b/demo/agent-server/src/agent_server/agents/tech_org/itsm/itsm.py @@ -12,7 +12,7 @@ from collections.abc import AsyncIterator from typing import Any -from .. import protocol +from agent_server import protocol logger = logging.getLogger("agent_server.itsm") diff --git a/demo/agent-server/src/agent_server/agents/itsm_agent.py b/demo/agent-server/src/agent_server/agents/tech_org/itsm/itsm_agent.py similarity index 99% rename from demo/agent-server/src/agent_server/agents/itsm_agent.py rename to demo/agent-server/src/agent_server/agents/tech_org/itsm/itsm_agent.py index 6a66f58..c11ed49 100644 --- a/demo/agent-server/src/agent_server/agents/itsm_agent.py +++ b/demo/agent-server/src/agent_server/agents/tech_org/itsm/itsm_agent.py @@ -308,7 +308,10 @@ def _enforced_agent() -> Any: # Invocation — yield LangChain astream_events items for the proxy. The event # projection + input coercion are shared with the HR agent in `langchain_events`; # `to_native_event` is re-exported here so `itsm.py` can call it off this module. -from .langchain_events import messages_input, to_native_event # noqa: E402, F401 +from agent_server.frameworks.langchain_events import ( # noqa: E402, F401 + messages_input, + to_native_event, +) async def stream(input: Any) -> AsyncIterator[Any]: diff --git a/demo/agent-server/src/agent_server/agents/itsm_db.py b/demo/agent-server/src/agent_server/agents/tech_org/itsm/itsm_db.py similarity index 99% rename from demo/agent-server/src/agent_server/agents/itsm_db.py rename to demo/agent-server/src/agent_server/agents/tech_org/itsm/itsm_db.py index 7423fc6..7dec322 100644 --- a/demo/agent-server/src/agent_server/agents/itsm_db.py +++ b/demo/agent-server/src/agent_server/agents/tech_org/itsm/itsm_db.py @@ -238,7 +238,7 @@ def set_state(number: str, new_state: str) -> dict[str, Any]: # --------------------------------------------------------------------------- -# Lifecycle board (UI widget). Global view, no per-user scope — see ui/itsm.yaml. +# Lifecycle board (UI widget). Global view, no per-user scope — see ui.yaml. # --------------------------------------------------------------------------- STATES = ["new", "Assess", "Authorize", "Schedule"] diff --git a/demo/agent-server/src/agent_server/ui/itsm.yaml b/demo/agent-server/src/agent_server/agents/tech_org/itsm/ui.yaml similarity index 100% rename from demo/agent-server/src/agent_server/ui/itsm.yaml rename to demo/agent-server/src/agent_server/agents/tech_org/itsm/ui.yaml diff --git a/demo/agent-server/src/agent_server/agents/base.py b/demo/agent-server/src/agent_server/base.py similarity index 100% rename from demo/agent-server/src/agent_server/agents/base.py rename to demo/agent-server/src/agent_server/base.py diff --git a/demo/agent-server/src/agent_server/config.py b/demo/agent-server/src/agent_server/config.py index 4e3e4fd..2ba62b0 100644 --- a/demo/agent-server/src/agent_server/config.py +++ b/demo/agent-server/src/agent_server/config.py @@ -10,15 +10,10 @@ class Settings: host: str = "127.0.0.1" port: int = 8880 - # Default OFF — the reference path is the deterministic echo agent so the - # contract is testable without a real provider key. Set AGENT_ENABLE_LLM=1 - # to route to the optional OpenAI-backed agent when a key is in the env. - enable_llm: bool = False def get_settings() -> Settings: return Settings( host=os.getenv("AGENT_HOST", "127.0.0.1"), port=int(os.getenv("AGENT_PORT", "8880")), - enable_llm=os.getenv("AGENT_ENABLE_LLM", "0") == "1", ) diff --git a/demo/agent-server/src/agent_server/frameworks/__init__.py b/demo/agent-server/src/agent_server/frameworks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/demo/agent-server/src/agent_server/agents/google_adk.py b/demo/agent-server/src/agent_server/frameworks/google_adk.py similarity index 100% rename from demo/agent-server/src/agent_server/agents/google_adk.py rename to demo/agent-server/src/agent_server/frameworks/google_adk.py diff --git a/demo/agent-server/src/agent_server/agents/langchain_events.py b/demo/agent-server/src/agent_server/frameworks/langchain_events.py similarity index 100% rename from demo/agent-server/src/agent_server/agents/langchain_events.py rename to demo/agent-server/src/agent_server/frameworks/langchain_events.py diff --git a/demo/agent-server/src/agent_server/agents/openai_agents.py b/demo/agent-server/src/agent_server/frameworks/openai_agents.py similarity index 100% rename from demo/agent-server/src/agent_server/agents/openai_agents.py rename to demo/agent-server/src/agent_server/frameworks/openai_agents.py diff --git a/demo/agent-server/src/agent_server/roster.py b/demo/agent-server/src/agent_server/roster.py index 6925189..f8c4b06 100644 --- a/demo/agent-server/src/agent_server/roster.py +++ b/demo/agent-server/src/agent_server/roster.py @@ -9,50 +9,22 @@ from pathlib import Path -_UI_DIR = Path(__file__).parent / "ui" +_AGENTS_DIR = Path(__file__).parent / "agents" + +# Each agent co-locates its UI config with its sources (one folder per agent). +_UI_PATHS = { + "devops": _AGENTS_DIR / "tech_org" / "devops" / "ui.yaml", + "itsm": _AGENTS_DIR / "tech_org" / "itsm" / "ui.yaml", + "healthcare": _AGENTS_DIR / "clinic_org" / "healthcare" / "ui.yaml", + "hr": _AGENTS_DIR / "shared" / "hr" / "ui.yaml", +} # id -> public roster entry. ``ui_url`` is relative to the agent-server root; # the proxy rewrites/serves it under its own /agents/{id}/ui. ``framework`` tells -# the proxy which translator to apply to this agent's native event stream — each -# reference agent demonstrates one framework path (`native` is the escape hatch). -# - Probe (native): the simple real-LLM chat showcase (OpenAI). -# - Orbit (google-adk): the "complex" showcase — a real LLM via Google ADK / -# Gemini PLUS a widget actions + data-source workspace (see ui/orbit.yaml). -# Both stream a real model when AGENT_ENABLE_LLM is set and the matching key is -# forwarded; otherwise they fall back (echo / canned ADK events). +# the proxy which translator to apply to this agent's native event stream +# (`native` is the escape hatch). Each entry is a real agent; HexGate wrapping is +# opt-in (enabled by setting HEXGATE_KEY). AGENTS: list[dict[str, str]] = [ - { - "id": "probe", - "name": "Probe", - "role": "General assistant", - "main_color": "#3f9d94", - "ui_url": "/agents/probe/ui", - "framework": "native", - }, - { - "id": "atlas", - "name": "Atlas", - "role": "Operations copilot", - "main_color": "#4f74c9", - "ui_url": "/agents/atlas/ui", - "framework": "langchain", - }, - { - "id": "forge", - "name": "Forge", - "role": "Code & build", - "main_color": "#56809e", - "ui_url": "/agents/forge/ui", - "framework": "openai-agents", - }, - { - "id": "orbit", - "name": "Orbit", - "role": "Research workspace", - "main_color": "#b0714f", - "ui_url": "/agents/orbit/ui", - "framework": "google-adk", - }, # Healthcare — a real OpenAI Agents SDK agent; HexGate wrapping is opt-in # (enabled by setting HEXGATE_KEY). { @@ -106,7 +78,7 @@ def get_agent(agent_id: str) -> dict[str, str] | None: def read_ui(agent_id: str) -> str | None: """Return the agent's ``ui.yaml`` text, or ``None`` when absent.""" - path = _UI_DIR / f"{agent_id}.yaml" - if not path.is_file(): + path = _UI_PATHS.get(agent_id) + if path is None or not path.is_file(): return None return path.read_text(encoding="utf-8") diff --git a/demo/agent-server/src/agent_server/routes/agents.py b/demo/agent-server/src/agent_server/routes/agents.py index f026b65..bcfc3e1 100644 --- a/demo/agent-server/src/agent_server/routes/agents.py +++ b/demo/agent-server/src/agent_server/routes/agents.py @@ -18,8 +18,8 @@ from .. import protocol from ..actions import run_action -from ..agents.select import select_agent from ..roster import AGENTS, get_agent, read_ui +from ..select import select_agent router = APIRouter(prefix="/agents", tags=["agents"]) diff --git a/demo/agent-server/src/agent_server/select.py b/demo/agent-server/src/agent_server/select.py new file mode 100644 index 0000000..d2b61fd --- /dev/null +++ b/demo/agent-server/src/agent_server/select.py @@ -0,0 +1,38 @@ +"""Pick the agent implementation for a request. + +One entry per org agent. Each wrapper picks its own plain-vs-HexGate path +internally, and is imported lazily so a missing framework/hexgate install only +affects the agent that needs it — not the whole roster. +""" + +from __future__ import annotations + +from typing import Any + +from .base import Agent + + +def select_agent(agent_id: str, context: dict[str, Any]) -> Agent: + if agent_id == "healthcare": + from .agents.clinic_org.healthcare.healthcare import HealthcareAgent + + return HealthcareAgent() + + if agent_id == "devops": + from .agents.tech_org.devops.devops import DevopsAgent + + return DevopsAgent() + + if agent_id == "itsm": + from .agents.tech_org.itsm.itsm import ItsmAgent + + return ItsmAgent() + + if agent_id == "hr": + from .agents.shared.hr.hr import HrAgent + + return HrAgent() + + # The route validates agent_id against the roster before calling this, so an + # unmapped id here means the roster and this dispatch are out of sync. + raise ValueError(f"No agent implementation for '{agent_id}'") diff --git a/demo/agent-server/src/agent_server/tools.py b/demo/agent-server/src/agent_server/tools.py deleted file mode 100644 index ef555e5..0000000 --- a/demo/agent-server/src/agent_server/tools.py +++ /dev/null @@ -1,35 +0,0 @@ -"""A couple of canned fake tools, so the reference stream exercises the -tool-call events without depending on anything external.""" - -from __future__ import annotations - -from typing import Any - - -def fake_search(query: str) -> dict[str, Any]: - """Return a canned search result set for ``query``.""" - q = query or "your query" - return { - "query": q, - "results": [ - { - "title": f"Result for '{q}'", - "url": "https://example.com/a", - "snippet": "A canned snippet returned by the reference agent.", - }, - { - "title": "Background reading", - "url": "https://example.com/b", - "snippet": "Another canned snippet.", - }, - ], - } - - -def fake_fetch(url: str) -> dict[str, Any]: - """Return canned page content for ``url``.""" - return { - "url": url, - "title": "Fetched page", - "content": f"Canned body for {url}.", - } diff --git a/demo/agent-server/src/agent_server/ui/atlas.yaml b/demo/agent-server/src/agent_server/ui/atlas.yaml deleted file mode 100644 index efca416..0000000 --- a/demo/agent-server/src/agent_server/ui/atlas.yaml +++ /dev/null @@ -1,39 +0,0 @@ -# Atlas — Operations copilot. Served by GET /agents/atlas/ui. -page: - layout_type: grid - main_color: "#4f74c9" - -widgets: - - name: ops-metrics - type: metrics - position: { horizontal: left, vertical: high } - size: { width: 12, height: auto } - columns: 3 - data_source: - action: "load_ops_metrics" - empty_text: "Metrics load when connected." - metrics: - - { id: "open_incidents", label: "Open incidents", format: "number" } - - { id: "uptime", label: "Uptime", format: "percent", precision: 2 } - - { id: "p95_latency", label: "p95 latency", format: "duration", suffix: " ms" } - - - name: transcript - type: ai-response - position: { horizontal: left, vertical: middle } - size: { width: 8, height: 460 } - empty_text: "Ask Atlas about operations." - thinking_indicator: dots - - - name: tool-calls - type: tool-calls - position: { horizontal: right, vertical: middle } - size: { width: 4, height: 460 } - title: Tool calls - empty_text: "Tool invocations appear here." - - - name: chat-input - type: ai-chat-input - position: { horizontal: left, vertical: low } - size: { width: 12, height: auto } - placeholder: "Ask Atlas…" - rows: 2 diff --git a/demo/agent-server/src/agent_server/ui/forge.yaml b/demo/agent-server/src/agent_server/ui/forge.yaml deleted file mode 100644 index 65d3441..0000000 --- a/demo/agent-server/src/agent_server/ui/forge.yaml +++ /dev/null @@ -1,26 +0,0 @@ -# Forge — Code & build. Served by GET /agents/forge/ui. -page: - layout_type: grid - main_color: "#56809e" - -widgets: - - name: tool-calls - type: tool-calls - position: { horizontal: left, vertical: high } - size: { width: 4, height: 520 } - title: Build steps - empty_text: "Build & tool steps appear here." - - - name: transcript - type: ai-response - position: { horizontal: center, vertical: high } - size: { width: 8, height: 520 } - empty_text: "Ask Forge to write or explain code." - thinking_indicator: dots - - - name: chat-input - type: ai-chat-input - position: { horizontal: left, vertical: low } - size: { width: 12, height: auto } - placeholder: "Ask Forge…" - rows: 2 diff --git a/demo/agent-server/src/agent_server/ui/orbit.yaml b/demo/agent-server/src/agent_server/ui/orbit.yaml deleted file mode 100644 index 6d3f3bd..0000000 --- a/demo/agent-server/src/agent_server/ui/orbit.yaml +++ /dev/null @@ -1,50 +0,0 @@ -# Orbit — Research workspace (Google ADK / Gemini). Served by GET /agents/orbit/ui. -# Showcases the widget actions + data-source system (CONTRACT.md §5b): the -# `sources` table reads its `data_source` action; the buttons fire side-effect -# actions and name `sources` in `refresh`, so it re-pulls. The agent stays the -# only color (main_color → --accent). -page: - layout_type: grid - main_color: "#b0714f" - -widgets: - - name: transcript - type: ai-response - position: { horizontal: left, vertical: high } - size: { width: 8, height: 420 } - empty_text: "Ask Orbit, or run a sample search to populate sources." - thinking_indicator: dots - - - name: tool-calls - type: tool-calls - position: { horizontal: right, vertical: high } - size: { width: 4, height: 420 } - title: Steps - empty_text: "Tool steps appear here." - - # Display widget — pulls its rows from the `list_sources` action, and re-pulls - # whenever an action below lists `sources` in `refresh`. - - name: sources - type: table - position: { horizontal: left, vertical: middle } - size: { width: 8, height: auto } - data_source: { action: list_sources } - has_header: true - empty_text: "No sources yet — run a sample search." - - # Side-effect actions. Each returns a `{result}` and re-pulls `sources`. - - name: source-actions - type: button-group - position: { horizontal: right, vertical: middle } - size: { width: 4, height: auto } - orientation: vertical - buttons: - - { label: "Run sample search", action: seed_sources, refresh: [sources] } - - { label: "Clear sources", action: clear_sources, variant: secondary, refresh: [sources] } - - - name: chat-input - type: ai-chat-input - position: { horizontal: left, vertical: low } - size: { width: 12, height: auto } - placeholder: "Ask Orbit…" - rows: 2 diff --git a/demo/agent-server/src/agent_server/ui/probe.yaml b/demo/agent-server/src/agent_server/ui/probe.yaml deleted file mode 100644 index 03fb163..0000000 --- a/demo/agent-server/src/agent_server/ui/probe.yaml +++ /dev/null @@ -1,20 +0,0 @@ -# Probe — Research & retrieval. Served by GET /agents/probe/ui. -# The active agent's main_color is the only color in the product. -page: - layout_type: grid - main_color: "#3f9d94" - -widgets: - - name: transcript - type: ai-response - position: { horizontal: left, vertical: high } - size: { width: 12, height: 520 } - empty_text: "Ask Probe to research something." - thinking_indicator: dots - - - name: chat-input - type: ai-chat-input - position: { horizontal: left, vertical: low } - size: { width: 12, height: auto } - placeholder: "Ask Probe…" - rows: 2