From bfa5d9b74bc3faed91efa0a9583589332c36c420 Mon Sep 17 00:00:00 2001 From: Rahul Javangula Date: Tue, 7 Jul 2026 00:17:31 -0400 Subject: [PATCH 1/3] feat: langgraph adapter for SEMOSS workspace agent configs --- .../agents/langgraph_agent/README.md | 96 ++++++ .../agents/langgraph_agent/__init__.py | 33 ++ .../agents/langgraph_agent/agent.py | 289 ++++++++++++++++++ .../agents/langgraph_agent/config.py | 81 +++++ .../agents/langgraph_agent/mcp_tools.py | 95 ++++++ py/genai_client/tests/test_langgraph_agent.py | 105 +++++++ py/install_config/pyproject.toml | 2 + 7 files changed, 701 insertions(+) create mode 100644 py/genai_client/agents/langgraph_agent/README.md create mode 100644 py/genai_client/agents/langgraph_agent/__init__.py create mode 100644 py/genai_client/agents/langgraph_agent/agent.py create mode 100644 py/genai_client/agents/langgraph_agent/config.py create mode 100644 py/genai_client/agents/langgraph_agent/mcp_tools.py create mode 100644 py/genai_client/tests/test_langgraph_agent.py diff --git a/py/genai_client/agents/langgraph_agent/README.md b/py/genai_client/agents/langgraph_agent/README.md new file mode 100644 index 00000000000..26d26f63df7 --- /dev/null +++ b/py/genai_client/agents/langgraph_agent/README.md @@ -0,0 +1,96 @@ +# SEMOSS ↔ LangGraph adapter + +Materialize a SEMOSS workspace agent config as a LangGraph +[`CompiledGraph`](https://langchain-ai.github.io/langgraph/reference/graphs/#compiledstategraph) +so anything that consumes LangGraph — LangSmith, LangGraph Studio, LangServe, +composition into another graph — can consume a SEMOSS-authored agent. + +## Quickstart + +```python +from genai_client.agents.langgraph_agent import SemossAgent + +agent = SemossAgent.from_workspace( + "93c85f32-1023-425d-8167-14111f26ceb4", + access_key="...", + secret_key="...", + room_id="babae1e3-42cb-490d-a622-c06e6a59da54", +) + +result = agent.invoke( + {"messages": [{"role": "user", "content": "summarize the recent news"}]} +) + +for chunk in agent.stream({"messages": [{"role": "user", "content": "..."}]}): + print(chunk) +``` + +The returned object is a stock LangGraph `CompiledGraph` — anything you would +do with `create_react_agent(...)` works identically here. + +## What the adapter maps + +| SEMOSS | LangGraph | +| --- | --- | +| `WORKSPACE.system_prompt` | react agent `prompt` | +| `WORKSPACE.model_engine_id` | LangChain `BaseChatModel` via `ModelEngine.to_langchain_chat_model()` | +| `WORKSPACE.mcp[]` | `BaseTool`s via `langchain-mcp-adapters` | +| `CONFIG_JSON.subagents[]` | Child `CompiledGraph`s wrapped as delegate tools | +| `CONFIG_JSON.mode == "deep"` | Routed through `deepagents.create_deep_agent` | + +## Deep mode + +Set `mode="deep"` on the workspace's `CONFIG_JSON` (or override at build time) +to route through [`deepagents`](https://docs.langchain.com/oss/python/deepagents/overview). +The child gets a planning tool (TodoWrite-style), a virtual filesystem, and +its subagents materialized in deepagents' native format. + +```python +agent = SemossAgent.from_workspace("...", mode="deep", ...) +``` + +Deep mode is entirely opt-in; workspaces without `mode` default to a plain +react agent. + +## Configuration surface + +`SemossAgentConfig` is a Pydantic model — use it directly when you want to +bypass the workspace fetch: + +```python +from genai_client.agents.langgraph_agent import ( + SemossAgent, + SemossAgentConfig, + MCPRef, + SubAgentRef, +) + +cfg = SemossAgentConfig( + system_prompt="You are a careful research assistant.", + model=my_chat_model, # BaseChatModel | ModelEngine | engine_id str + mcps=[MCPRef(url="...", name="search")], + subagents=[SubAgentRef(alias="researcher", workspaceId="ddd2a191-...")], + mode="react", + access_key="...", secret_key="...", room_id="...", +) +agent = SemossAgent.from_config(cfg) +``` + +## External usage + +`from_workspace` fetches via `semoss.Insight().run_pixel(...)` by default, +which requires running inside a SEMOSS Python runtime. For external LangGraph +apps, supply a `pixel_loader` callable that hits the SEMOSS REST endpoint: + +```python +def my_loader(pixel: str) -> dict: + ... + +agent = SemossAgent.from_workspace("...", pixel_loader=my_loader, ...) +``` + +## Depth guard + +`max_subagent_depth` (default 1) mirrors +`AgentConfig.SubAgentSpawnPolicy.DEFAULT_MAX_SUBAGENT_DEPTH`. Increase only +if you understand the risk of unbounded delegation. diff --git a/py/genai_client/agents/langgraph_agent/__init__.py b/py/genai_client/agents/langgraph_agent/__init__.py new file mode 100644 index 00000000000..c4ea46f47bb --- /dev/null +++ b/py/genai_client/agents/langgraph_agent/__init__.py @@ -0,0 +1,33 @@ +"""SEMOSS ↔ LangGraph adapter. + +Materializes a SEMOSS workspace configuration as a LangGraph +``CompiledGraph`` so that ``langgraph``, ``langsmith`` and downstream +tooling can consume a SEMOSS-authored agent without knowing SEMOSS is the +source of truth. + +Usage (in-SEMOSS):: + + from genai_client.agents.langgraph_agent import SemossAgent + + agent = SemossAgent.from_workspace( + "93c85f32-1023-425d-8167-14111f26ceb4", + access_key="...", + secret_key="...", + room_id="babae1e3-...", + ) + result = agent.invoke({"messages": [{"role": "user", "content": "hi"}]}) + +Deep-mode (planning tool + virtual filesystem + subagents) via +``mode="deep"`` on the workspace config or overridden at build time. +""" + +from .agent import SemossAgent, build_agent +from .config import MCPRef, SemossAgentConfig, SubAgentRef + +__all__ = [ + "SemossAgent", + "SemossAgentConfig", + "MCPRef", + "SubAgentRef", + "build_agent", +] diff --git a/py/genai_client/agents/langgraph_agent/agent.py b/py/genai_client/agents/langgraph_agent/agent.py new file mode 100644 index 00000000000..7f283bfdf12 --- /dev/null +++ b/py/genai_client/agents/langgraph_agent/agent.py @@ -0,0 +1,289 @@ +"""Build a LangGraph ``CompiledGraph`` from a SEMOSS workspace. + +Two entry points: + +* ``SemossAgent.from_config(cfg)`` — pure, unit-testable. The caller is + responsible for supplying an already-resolved + :class:`~genai_client.agents.langgraph_agent.config.SemossAgentConfig` + (including any model wrapping). + +* ``SemossAgent.from_workspace(workspace_id, ...)`` — convenience wrapper + that fetches ``CONFIG_JSON`` for the workspace and any recursively + referenced subagents via a Pixel loader. + +Both return a ``CompiledGraph`` that behaves like any hand-authored +LangGraph agent: ``.invoke``, ``.stream``, ``.get_state``, composition, +LangSmith tracing. +""" + +from __future__ import annotations + +import json +import logging +from typing import Any, Callable, List, Literal, Optional + +from langchain_core.language_models import BaseChatModel +from langchain_core.tools import BaseTool, tool +from langgraph.prebuilt import create_react_agent + +from .config import MCPRef, SemossAgentConfig, SubAgentRef +from .mcp_tools import load_mcp_tools + +logger = logging.getLogger(__name__) + + +PixelLoader = Callable[[str], dict] +"""Callable that runs a Pixel string and returns the first output object.""" + + +def _default_pixel_loader() -> PixelLoader: + """Return the in-SEMOSS default loader backed by ``semoss.Insight``. + + Raises at call time (not import time) so external callers who supply + their own loader do not pay the import cost. + """ + + def _run(pixel: str) -> dict: + from semoss import Insight + + result = Insight().run_pixel(pixel=pixel, raw=True) + # `raw=True` returns the full pixelReturn envelope. + outputs = result[0].get("pixelReturn") if result else None + if not outputs: + return {} + first = outputs[-1].get("output") + return first if isinstance(first, dict) else {"value": first} + + return _run + + +def _wrap_model(model: Any) -> BaseChatModel: + """Coerce ``model`` into a LangChain chat model. + + Accepts an already-wrapped chat model, a SEMOSS ``ModelEngine`` (via + duck-typed ``to_langchain_chat_model``), or a model engine id string + (which triggers a ``ModelEngine`` instantiation). + """ + + if isinstance(model, BaseChatModel): + return model + if hasattr(model, "to_langchain_chat_model"): + return model.to_langchain_chat_model() + if isinstance(model, str): + from gaas_gpt_model import ModelEngine + + return ModelEngine(engine_id=model).to_langchain_chat_model() + raise TypeError( + f"Unsupported model type: {type(model).__name__}. " + "Expected BaseChatModel, ModelEngine, or engine_id string." + ) + + +def _parse_config_json(raw: Any) -> dict: + if raw is None: + return {} + if isinstance(raw, str): + try: + return json.loads(raw) or {} + except json.JSONDecodeError: + return {} + if isinstance(raw, dict): + return raw + return {} + + +def _config_from_workspace_output(output: dict) -> dict: + """Extract the adapter-relevant fields from a ``GetWorkspace`` output.""" + + config_json = _parse_config_json(output.get("config_json")) + return { + "workspace_id": output.get("workspace_id") or output.get("id"), + "name": output.get("name"), + "description": output.get("description"), + "system_prompt": output.get("system_prompt"), + "mcps": output.get("mcp") or [], + "subagents": config_json.get("subagents") or [], + "mode": (config_json.get("mode") or "react").lower(), + "model_id": output.get("model_id") or config_json.get("model_id"), + } + + +def _build_subagent_tool( + ref: SubAgentRef, + child_graph: Any, +) -> BaseTool: + alias = ref.alias + description = ( + ref.description + or f"Delegate the task to the '{alias}' subagent and return its final answer." + ) + + @tool(alias, description=description) + def _delegate(task: str) -> str: + result = child_graph.invoke({"messages": [{"role": "user", "content": task}]}) + messages = result.get("messages", []) if isinstance(result, dict) else [] + if not messages: + return "" + last = messages[-1] + return getattr(last, "content", None) or ( + last.get("content", "") if isinstance(last, dict) else "" + ) + + return _delegate + + +def _build_deep_subagents( + subagents: List[SubAgentRef], + load_pixel: PixelLoader, + access_key: Optional[str], + secret_key: Optional[str], + room_id: Optional[str], +) -> List[dict]: + """Materialize SEMOSS subagents into the dict shape deepagents wants.""" + + out: List[dict] = [] + for ref in subagents: + ws = load_pixel(f'GetWorkspace(workspaceId=["{ref.workspace_id}"]);') + fields = _config_from_workspace_output(ws) + child_mcps = [MCPRef(**m) for m in fields.get("mcps") or []] + child_tools = load_mcp_tools(child_mcps, access_key, secret_key, room_id) + out.append( + { + "name": ref.alias, + "description": ref.description or fields.get("description") or ref.alias, + "prompt": fields.get("system_prompt") or "", + "tools": child_tools, + } + ) + return out + + +class SemossAgent: + """Namespace of factory methods that return LangGraph ``CompiledGraph``s.""" + + @staticmethod + def from_config(config: SemossAgentConfig, *, _depth: int = 0) -> Any: + if config.model is None: + raise ValueError("SemossAgentConfig.model is required.") + model = _wrap_model(config.model) + tools = load_mcp_tools( + config.mcps, config.access_key, config.secret_key, config.room_id + ) + + if config.subagents and _depth >= config.max_subagent_depth: + logger.info( + "Subagent depth cap reached at depth=%d; skipping %d subagent(s).", + _depth, + len(config.subagents), + ) + elif config.subagents: + for ref in config.subagents: + child = SemossAgent.from_workspace( + workspace_id=ref.workspace_id, + access_key=config.access_key, + secret_key=config.secret_key, + room_id=config.room_id, + max_subagent_depth=config.max_subagent_depth, + _depth=_depth + 1, + ) + tools.append(_build_subagent_tool(ref, child)) + + if config.mode == "deep": + return _build_deep_graph(config, model, tools) + + return create_react_agent( + model=model, + tools=tools, + prompt=config.system_prompt, + ) + + @staticmethod + def from_workspace( + workspace_id: str, + *, + model: Any = None, + access_key: Optional[str] = None, + secret_key: Optional[str] = None, + room_id: Optional[str] = None, + mode: Optional[Literal["react", "deep"]] = None, + max_subagent_depth: int = 1, + pixel_loader: Optional[PixelLoader] = None, + _depth: int = 0, + ) -> Any: + loader = pixel_loader or _default_pixel_loader() + raw = loader(f'GetWorkspace(workspaceId=["{workspace_id}"]);') + fields = _config_from_workspace_output(raw) + + resolved_model = model + if resolved_model is None: + model_id = fields.get("model_id") + if not model_id: + raise ValueError( + f"Workspace {workspace_id} has no model configured and none was passed." + ) + resolved_model = model_id + + cfg = SemossAgentConfig( + workspace_id=fields["workspace_id"], + name=fields.get("name"), + description=fields.get("description"), + system_prompt=fields.get("system_prompt"), + model=resolved_model, + mcps=[MCPRef(**m) for m in fields.get("mcps") or []], + subagents=[ + SubAgentRef.model_validate(s) for s in fields.get("subagents") or [] + ], + mode=(mode or fields.get("mode") or "react"), + access_key=access_key, + secret_key=secret_key, + room_id=room_id, + max_subagent_depth=max_subagent_depth, + ) + return SemossAgent.from_config(cfg, _depth=_depth) + + +def _build_deep_graph( + config: SemossAgentConfig, model: BaseChatModel, tools: List[BaseTool] +) -> Any: + try: + from deepagents import create_deep_agent + except ImportError as exc: # pragma: no cover - guarded + raise ImportError( + "mode='deep' requires the 'deepagents' package. " + "Install with: pip install deepagents" + ) from exc + + deep_subagents = _build_deep_subagents( + config.subagents, + _default_pixel_loader(), + config.access_key, + config.secret_key, + config.room_id, + ) + return create_deep_agent( + tools=tools, + model=model, + instructions=config.system_prompt or "", + subagents=deep_subagents, + ) + + +def build_agent( + workspace_id: str, + *, + access_key: Optional[str] = None, + secret_key: Optional[str] = None, + room_id: Optional[str] = None, + mode: Optional[Literal["react", "deep"]] = None, + model: Any = None, +) -> Any: + """Shorthand for :meth:`SemossAgent.from_workspace`.""" + + return SemossAgent.from_workspace( + workspace_id=workspace_id, + access_key=access_key, + secret_key=secret_key, + room_id=room_id, + mode=mode, + model=model, + ) diff --git a/py/genai_client/agents/langgraph_agent/config.py b/py/genai_client/agents/langgraph_agent/config.py new file mode 100644 index 00000000000..ac9c88deb14 --- /dev/null +++ b/py/genai_client/agents/langgraph_agent/config.py @@ -0,0 +1,81 @@ +"""Pydantic models mirroring the SEMOSS workspace agent config surface. + +These are the fields the LangGraph adapter reads. They map onto Java +``AgentConfig`` / ``SubAgentSpec`` shapes and the ``WORKSPACE.CONFIG_JSON`` +JSON blob populated by the workspace-editor UI. +""" + +from __future__ import annotations + +from typing import Any, List, Literal, Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class MCPRef(BaseModel): + """A SEMOSS MCP resource attached to a workspace. + + Auth-aware MCP servers are reached over HTTP with a bearer of + ``::room-`` (same pattern the + Claude Code adapter uses). + """ + + url: str + name: str + type: Optional[str] = None + description: Optional[str] = None + + model_config = ConfigDict(extra="ignore") + + +class SubAgentRef(BaseModel): + """A named subagent declared in ``CONFIG_JSON.subagents[]``. + + Mirrors ``prerna.reactor.agent.config.SubAgentSpec``. Alias is the + tool name the LLM sees; workspace_id points at another workspace + whose config is loaded for the child run. + """ + + alias: str + workspace_id: str = Field(..., alias="workspaceId") + description: Optional[str] = None + + model_config = ConfigDict(populate_by_name=True, extra="ignore") + + +class SemossAgentConfig(BaseModel): + """Resolved agent config the adapter builds a ``CompiledGraph`` from. + + Only ``system_prompt`` and ``model`` are effectively required for a + working react agent; every other field enriches the graph. + + ``model`` accepts a model engine id (``str``), a + ``langchain_core.language_models.BaseChatModel`` instance, or a + SEMOSS ``ModelEngine`` (which gets wrapped via + ``ModelEngine.to_langchain_chat_model()``). + + ``mode`` chooses between vanilla ``create_react_agent`` and + ``deepagents.create_deep_agent`` (planning + filesystem + subagents). + """ + + workspace_id: Optional[str] = None + name: Optional[str] = None + description: Optional[str] = None + + system_prompt: Optional[str] = None + model: Any = None + + mcps: List[MCPRef] = Field(default_factory=list) + subagents: List[SubAgentRef] = Field(default_factory=list) + + mode: Literal["react", "deep"] = "react" + + # Auth used to reach MCP servers and to fetch child workspaces + access_key: Optional[str] = None + secret_key: Optional[str] = None + room_id: Optional[str] = None + + # Subagent depth guard. Mirrors AgentConfig.SubAgentSpawnPolicy default of 1. + max_subagent_depth: int = 1 + + model_config = ConfigDict(arbitrary_types_allowed=True, extra="ignore") diff --git a/py/genai_client/agents/langgraph_agent/mcp_tools.py b/py/genai_client/agents/langgraph_agent/mcp_tools.py new file mode 100644 index 00000000000..d6dfe0af6be --- /dev/null +++ b/py/genai_client/agents/langgraph_agent/mcp_tools.py @@ -0,0 +1,95 @@ +"""SEMOSS MCPs → LangChain tools. + +Uses ``langchain-mcp-adapters`` when available. The lookup happens lazily +so importing this module does not fail environments that do not have the +adapter installed; ``load_mcp_tools`` degrades to returning an empty list +with a warning in that case. +""" + +from __future__ import annotations + +import logging +from typing import Any, List, Optional + +from .config import MCPRef + +logger = logging.getLogger(__name__) + + +def _bearer_headers(access_key: str, secret_key: str, room_id: str) -> dict: + token = f"{access_key}:{secret_key}:room-{room_id}" + return {"Authorization": f"Bearer {token}"} + + +def _server_name(mcp: MCPRef) -> str: + return (mcp.name or "mcp").replace(" ", "_").lower() + + +async def _load_async( + mcps: List[MCPRef], + access_key: str, + secret_key: str, + room_id: str, +) -> List[Any]: + from langchain_mcp_adapters.client import MultiServerMCPClient + + servers = { + _server_name(mcp): { + "url": mcp.url, + "transport": "streamable_http", + "headers": _bearer_headers(access_key, secret_key, room_id), + } + for mcp in mcps + } + client = MultiServerMCPClient(servers) + return await client.get_tools() + + +def load_mcp_tools( + mcps: List[MCPRef], + access_key: Optional[str], + secret_key: Optional[str], + room_id: Optional[str], +) -> List[Any]: + """Return LangChain ``BaseTool``s for the provided SEMOSS MCPs. + + Returns ``[]`` when there are no MCPs, when auth is missing, or when + ``langchain-mcp-adapters`` is not installed. + """ + + if not mcps: + return [] + if not (access_key and secret_key and room_id): + logger.warning( + "load_mcp_tools: skipping %d MCP(s); access_key/secret_key/room_id are required.", + len(mcps), + ) + return [] + + try: + import asyncio + + try: + loop = asyncio.get_running_loop() + except RuntimeError: + loop = None + + coro = _load_async(mcps, access_key, secret_key, room_id) + if loop is None: + return asyncio.run(coro) + + # In a running loop the caller is responsible for awaiting; fall + # back to a fresh loop in a thread to keep the sync API simple. + import concurrent.futures + + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: + return pool.submit(asyncio.run, coro).result() + except ImportError: + logger.warning( + "langchain-mcp-adapters is not installed; %d MCP(s) will be skipped.", + len(mcps), + ) + return [] + except Exception as exc: + logger.warning("load_mcp_tools failed: %s", exc) + return [] diff --git a/py/genai_client/tests/test_langgraph_agent.py b/py/genai_client/tests/test_langgraph_agent.py new file mode 100644 index 00000000000..a35e0067327 --- /dev/null +++ b/py/genai_client/tests/test_langgraph_agent.py @@ -0,0 +1,105 @@ +"""Smoke tests for the SEMOSS ↔ LangGraph adapter. + +Runs offline: no model calls, no MCP servers, no pixel round-trips. Uses +LangChain's ``FakeListChatModel`` to construct a real ``CompiledGraph`` +and asserts basic shape. + +Run from ``Semoss_Dev/py``:: + + py -3.12 -m unittest genai_client.tests.test_langgraph_agent +""" + +from __future__ import annotations + +import json +import unittest + + +class LangGraphAdapterTests(unittest.TestCase): + def _fake_model(self): + from langchain_community.chat_models.fake import FakeListChatModel + + return FakeListChatModel(responses=["ok"]) + + def test_from_config_builds_react_graph(self): + from genai_client.agents.langgraph_agent import ( + SemossAgent, + SemossAgentConfig, + ) + + cfg = SemossAgentConfig( + system_prompt="You are a helper.", + model=self._fake_model(), + ) + graph = SemossAgent.from_config(cfg) + self.assertTrue(hasattr(graph, "invoke")) + self.assertTrue(hasattr(graph, "stream")) + + def test_from_config_rejects_missing_model(self): + from genai_client.agents.langgraph_agent import ( + SemossAgent, + SemossAgentConfig, + ) + + with self.assertRaises(ValueError): + SemossAgent.from_config(SemossAgentConfig(system_prompt="x")) + + def test_subagent_ref_accepts_camelcase_and_snake_case(self): + from genai_client.agents.langgraph_agent import SubAgentRef + + camel = SubAgentRef.model_validate( + {"alias": "researcher", "workspaceId": "abc", "description": "d"} + ) + snake = SubAgentRef.model_validate( + {"alias": "researcher", "workspace_id": "abc"} + ) + self.assertEqual(camel.workspace_id, "abc") + self.assertEqual(snake.workspace_id, "abc") + + def test_from_workspace_uses_pixel_loader(self): + from genai_client.agents.langgraph_agent import SemossAgent + + model = self._fake_model() + seen = [] + + def loader(pixel: str): + seen.append(pixel) + return { + "workspace_id": "root-ws", + "name": "Root", + "system_prompt": "prompt", + "mcp": [], + "config_json": json.dumps({"subagents": [], "mode": "react"}), + } + + graph = SemossAgent.from_workspace( + "root-ws", model=model, pixel_loader=loader + ) + self.assertTrue(hasattr(graph, "invoke")) + self.assertEqual(len(seen), 1) + self.assertIn("root-ws", seen[0]) + + def test_config_json_string_parses(self): + from genai_client.agents.langgraph_agent.agent import ( + _config_from_workspace_output, + ) + + out = _config_from_workspace_output( + { + "workspace_id": "w", + "config_json": json.dumps( + { + "subagents": [ + {"alias": "r", "workspaceId": "child", "description": "d"} + ], + "mode": "deep", + } + ), + } + ) + self.assertEqual(out["mode"], "deep") + self.assertEqual(len(out["subagents"]), 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/py/install_config/pyproject.toml b/py/install_config/pyproject.toml index bdc86e14691..dfa7e72754f 100644 --- a/py/install_config/pyproject.toml +++ b/py/install_config/pyproject.toml @@ -42,6 +42,8 @@ dependencies = [ "langchain-text-splitters>=1.1.2", "langextract>=1.3.0", "langgraph>=1.1.10", + "langchain-mcp-adapters>=0.1.0", + "deepagents>=0.0.15", "livekit>=1.0.17", "lm-format-enforcer>=0.10.11", "loralib>=0.1.2", From cd5ac593bfb60664046d7a8581f294d52570bc2f Mon Sep 17 00:00:00 2001 From: Rahul Javangula Date: Tue, 7 Jul 2026 00:29:16 -0400 Subject: [PATCH 2/3] feat: bind_tools + tool_call parsing on SemossLangchainChatModel --- py/gaas_gpt_model.py | 74 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 73 insertions(+), 1 deletion(-) diff --git a/py/gaas_gpt_model.py b/py/gaas_gpt_model.py index 3ad5fd389d2..4e8250c8750 100644 --- a/py/gaas_gpt_model.py +++ b/py/gaas_gpt_model.py @@ -724,6 +724,32 @@ class Config: allow_population_by_field_name = True + def bind_tools( + self, + tools: List[Any], + *, + tool_choice: Optional[Any] = None, + **kwargs: Any, + ): + """Bind LangChain tools onto the chat model. + + Tools are converted to the OpenAI function-schema shape SEMOSS + already normalizes on (see ``semoss_base.semoss_message_builder`` + for the canonical tool_call dict). This is what makes the model + usable inside ``langgraph.prebuilt.create_react_agent`` and any + downstream framework that speaks LangChain's tool-calling + protocol. + """ + from langchain_core.utils.function_calling import ( + convert_to_openai_tool, + ) + + formatted = [convert_to_openai_tool(t) for t in tools] + bind_kwargs: Dict[str, Any] = {"tools": formatted, **kwargs} + if tool_choice is not None: + bind_kwargs["tool_choice"] = tool_choice + return self.bind(**bind_kwargs) + def _generate( self, messages: List[BaseMessage], @@ -746,6 +772,46 @@ def _generate( return self._create_chat_result(response=response[0]) + def _extract_tool_calls(self, response: Dict[str, Any]) -> List[Dict[str, Any]]: + """Return LangChain-shaped tool_calls from a raw model response. + + Handles the three shapes SEMOSS providers commonly return: + openai-style ``tool_calls``, anthropic-style ``tool_use`` + blocks, and gemini-style ``function_calls``. Returns ``[]`` + when nothing tool-shaped is present. + """ + import json as _json + + raw = ( + response.pop("tool_calls", None) + or response.pop("tool_uses", None) + or response.pop("function_calls", None) + ) + if not raw: + return [] + + normalized: List[Dict[str, Any]] = [] + for i, item in enumerate(raw): + fn = item.get("function") or item + name = fn.get("name") or item.get("name") + args = fn.get("arguments") or item.get("input") or {} + if isinstance(args, str): + try: + args = _json.loads(args) + except Exception: + args = {"_raw": args} + if not name: + continue + normalized.append( + { + "name": name, + "args": args, + "id": str(item.get("id") or f"call_{i}"), + "type": "tool_call", + } + ) + return normalized + def _create_chat_result(self, response: Dict[str, Any]) -> ChatResult: generations = [] @@ -753,8 +819,14 @@ def _create_chat_result(self, response: Dict[str, Any]) -> ChatResult: generation_info = dict() if "logprobs" in response.keys(): generation_info["logprobs"] = response.pop("logprobs", {}) + + tool_calls = self._extract_tool_calls(response) + ai_kwargs: Dict[str, Any] = {"content": message} + if tool_calls: + ai_kwargs["tool_calls"] = tool_calls + gen = ChatGeneration( - message=AIMessage(content=message), + message=AIMessage(**ai_kwargs), generation_info=generation_info, ) From f6481810c3b050c3c3d793ed8c28db0e1d701f1d Mon Sep 17 00:00:00 2001 From: Rahul Javangula Date: Wed, 8 Jul 2026 21:16:38 -0400 Subject: [PATCH 3/3] fix: propagate pixel_loader to subagents, surface config/pixel errors --- .../agents/langgraph_agent/agent.py | 35 +++++++++++++++---- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/py/genai_client/agents/langgraph_agent/agent.py b/py/genai_client/agents/langgraph_agent/agent.py index 7f283bfdf12..6ec11df8d20 100644 --- a/py/genai_client/agents/langgraph_agent/agent.py +++ b/py/genai_client/agents/langgraph_agent/agent.py @@ -50,7 +50,10 @@ def _run(pixel: str) -> dict: # `raw=True` returns the full pixelReturn envelope. outputs = result[0].get("pixelReturn") if result else None if not outputs: - return {} + raise RuntimeError( + f"Pixel returned no output: {pixel!r}. This usually means the " + "workspace/engine id is wrong or the current user lacks access." + ) first = outputs[-1].get("output") return first if isinstance(first, dict) else {"value": first} @@ -85,10 +88,19 @@ def _parse_config_json(raw: Any) -> dict: if isinstance(raw, str): try: return json.loads(raw) or {} - except json.JSONDecodeError: + except json.JSONDecodeError as e: + logger.warning( + "Workspace CONFIG_JSON failed to parse (%s); subagents/mode " + "from it will be ignored. Raw prefix: %r", + e, raw[:120] if len(raw) > 120 else raw, + ) return {} if isinstance(raw, dict): return raw + logger.warning( + "Workspace CONFIG_JSON has unexpected type %s; expected str or dict.", + type(raw).__name__, + ) return {} @@ -162,7 +174,12 @@ class SemossAgent: """Namespace of factory methods that return LangGraph ``CompiledGraph``s.""" @staticmethod - def from_config(config: SemossAgentConfig, *, _depth: int = 0) -> Any: + def from_config( + config: SemossAgentConfig, + *, + pixel_loader: Optional[PixelLoader] = None, + _depth: int = 0, + ) -> Any: if config.model is None: raise ValueError("SemossAgentConfig.model is required.") model = _wrap_model(config.model) @@ -184,12 +201,13 @@ def from_config(config: SemossAgentConfig, *, _depth: int = 0) -> Any: secret_key=config.secret_key, room_id=config.room_id, max_subagent_depth=config.max_subagent_depth, + pixel_loader=pixel_loader, _depth=_depth + 1, ) tools.append(_build_subagent_tool(ref, child)) if config.mode == "deep": - return _build_deep_graph(config, model, tools) + return _build_deep_graph(config, model, tools, pixel_loader) return create_react_agent( model=model, @@ -239,11 +257,14 @@ def from_workspace( room_id=room_id, max_subagent_depth=max_subagent_depth, ) - return SemossAgent.from_config(cfg, _depth=_depth) + return SemossAgent.from_config(cfg, pixel_loader=pixel_loader, _depth=_depth) def _build_deep_graph( - config: SemossAgentConfig, model: BaseChatModel, tools: List[BaseTool] + config: SemossAgentConfig, + model: BaseChatModel, + tools: List[BaseTool], + pixel_loader: Optional[PixelLoader] = None, ) -> Any: try: from deepagents import create_deep_agent @@ -255,7 +276,7 @@ def _build_deep_graph( deep_subagents = _build_deep_subagents( config.subagents, - _default_pixel_loader(), + pixel_loader or _default_pixel_loader(), config.access_key, config.secret_key, config.room_id,