mutiny-openai-agents 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,31 @@
1
+ .env
2
+ .env.*
3
+ !.env.example
4
+ __pycache__/
5
+ *.py[cod]
6
+ *$py.class
7
+ *.egg-info/
8
+ .eggs/
9
+ dist/
10
+ build/
11
+ .pytest_cache/
12
+ .mypy_cache/
13
+ .ruff_cache/
14
+ .venv/
15
+ venv/
16
+ .DS_Store
17
+ *.sqlite
18
+ *.db
19
+ data/
20
+ .coverage
21
+ htmlcov/
22
+ node_modules/
23
+ .next/
24
+ .worktrees/
25
+ .mutiny/
26
+ !examples/openai_support_agent/.mutiny/
27
+ !examples/openai_support_agent/.mutiny/**
28
+
29
+
30
+ .vscode/
31
+ .idea/
@@ -0,0 +1,12 @@
1
+ Metadata-Version: 2.4
2
+ Name: mutiny-openai-agents
3
+ Version: 0.1.0
4
+ Summary: Mutiny Adapter #1 — OpenAI Agents SDK TargetAdapter
5
+ Project-URL: Homepage, https://github.com/CodewithJha/mutiny
6
+ Project-URL: Repository, https://github.com/CodewithJha/mutiny
7
+ Project-URL: Issues, https://github.com/CodewithJha/mutiny/issues
8
+ Author-email: Priyanshu Jha <155089480+CodewithJha@users.noreply.github.com>
9
+ License-Expression: MIT
10
+ Requires-Python: >=3.11
11
+ Requires-Dist: mutiny-core>=0.1.0
12
+ Requires-Dist: openai-agents>=0.19
@@ -0,0 +1,31 @@
1
+ [project]
2
+ name = "mutiny-openai-agents"
3
+ version = "0.1.0"
4
+ description = "Mutiny Adapter #1 — OpenAI Agents SDK TargetAdapter"
5
+ requires-python = ">=3.11"
6
+ license = "MIT"
7
+ authors = [
8
+ { name = "Priyanshu Jha", email = "155089480+CodewithJha@users.noreply.github.com" },
9
+ ]
10
+ dependencies = [
11
+ "mutiny-core>=0.1.0",
12
+ "openai-agents>=0.19",
13
+ ]
14
+
15
+ [project.urls]
16
+ Homepage = "https://github.com/CodewithJha/mutiny"
17
+ Repository = "https://github.com/CodewithJha/mutiny"
18
+ Issues = "https://github.com/CodewithJha/mutiny/issues"
19
+
20
+ [build-system]
21
+ requires = ["hatchling"]
22
+ build-backend = "hatchling.build"
23
+
24
+ [tool.hatch.build.targets.wheel]
25
+ packages = ["src/mutiny_openai_agents"]
26
+
27
+ [tool.hatch.build.targets.sdist]
28
+ include = [
29
+ "/src",
30
+ "/pyproject.toml",
31
+ ]
@@ -0,0 +1,12 @@
1
+ """OpenAI Agents SDK adapter — Mutiny Adapter #1 (ADR-018)."""
2
+
3
+ from mutiny_openai_agents.adapter import OpenAIAgentsAdapter
4
+ from mutiny_openai_agents.loader import load_agent_from_ref, load_callable
5
+ from mutiny_openai_agents.sample import make_openai_support_adapter
6
+
7
+ __all__ = [
8
+ "OpenAIAgentsAdapter",
9
+ "load_agent_from_ref",
10
+ "load_callable",
11
+ "make_openai_support_adapter",
12
+ ]
@@ -0,0 +1,138 @@
1
+ """OpenAIAgentsAdapter — TargetAdapter for OpenAI Agents SDK projects."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ from collections.abc import Callable
7
+ from typing import Any
8
+
9
+ from agents import Agent, Runner
10
+ from agents.memory.sqlite_session import SQLiteSession
11
+
12
+ from mutiny_core.adapter.port import TargetAdapter, ToolsNotObservableError
13
+ from mutiny_core.trace.models import AdapterTurnResult
14
+
15
+ from mutiny_openai_agents.extract import (
16
+ extract_assistant_message,
17
+ extract_tool_calls,
18
+ extract_tool_results,
19
+ summarize_items,
20
+ tools_observable,
21
+ )
22
+ from mutiny_openai_agents.loader import load_agent_from_ref
23
+
24
+
25
+ class OpenAIAgentsAdapter(TargetAdapter):
26
+ """Drive an OpenAI Agents SDK ``Agent`` and observe tool calls.
27
+
28
+ Framework-specific imports stay inside this package (ADR-018). Core only
29
+ sees ``TargetAdapter`` / ``AdapterTurnResult``.
30
+ """
31
+
32
+ def __init__(
33
+ self,
34
+ agent: Agent[Any] | None = None,
35
+ *,
36
+ agent_ref: str | None = None,
37
+ agent_factory: Callable[[], Agent[Any]] | None = None,
38
+ context: dict[str, Any] | None = None,
39
+ context_provider: Callable[[], dict[str, Any]] | None = None,
40
+ session_factory: Callable[[str], Any] | None = None,
41
+ on_reset: Callable[[], None] | None = None,
42
+ max_turns: int = 10,
43
+ ) -> None:
44
+ if agent is None and agent_ref is None and agent_factory is None:
45
+ raise ValueError(
46
+ "OpenAIAgentsAdapter requires agent=, agent_ref=, or agent_factory="
47
+ )
48
+ self._agent = agent
49
+ self._agent_ref = agent_ref
50
+ self._agent_factory = agent_factory
51
+ self._context = context or {}
52
+ self._context_provider = context_provider
53
+ self._session_factory = session_factory or (
54
+ lambda sid: SQLiteSession(sid, db_path=":memory:")
55
+ )
56
+ self._on_reset = on_reset
57
+ self._max_turns = max_turns
58
+ self._sessions: dict[str, Any] = {}
59
+ self._resolved_agent: Agent[Any] | None = None
60
+
61
+ def reset(self, session_id: str) -> None:
62
+ """Start a fresh conversation session (in-memory SQLite by default)."""
63
+ if self._on_reset is not None:
64
+ self._on_reset()
65
+ previous = self._sessions.pop(session_id, None)
66
+ if previous is not None and hasattr(previous, "close"):
67
+ try:
68
+ previous.close()
69
+ except Exception: # noqa: BLE001
70
+ pass
71
+ self._sessions[session_id] = self._session_factory(session_id)
72
+
73
+ def step(self, session_id: str, user_message: str) -> AdapterTurnResult:
74
+ """Run one user turn via ``Runner.run_sync``; map tools → Mutiny traces."""
75
+ if session_id not in self._sessions:
76
+ self.reset(session_id)
77
+
78
+ agent = self._get_agent()
79
+ session = self._sessions[session_id]
80
+
81
+ try:
82
+ result = Runner.run_sync(
83
+ agent,
84
+ user_message,
85
+ session=session,
86
+ max_turns=self._max_turns,
87
+ )
88
+ except ToolsNotObservableError:
89
+ raise
90
+ except Exception as exc: # noqa: BLE001
91
+ # Surface SDK failures without inventing tool evidence
92
+ raise RuntimeError(
93
+ f"OpenAI Agents SDK run failed for session={session_id}: {exc}"
94
+ ) from exc
95
+
96
+ if not tools_observable(result):
97
+ raise ToolsNotObservableError(
98
+ "OpenAI Agents SDK result does not expose new_items; "
99
+ "cannot observe tool calls for policy evidence"
100
+ )
101
+
102
+ tool_calls = extract_tool_calls(result)
103
+ tool_results = extract_tool_results(result)
104
+ assistant = extract_assistant_message(result)
105
+
106
+ return AdapterTurnResult(
107
+ assistant_message=assistant,
108
+ tool_calls=tool_calls,
109
+ tool_results=tool_results,
110
+ raw={
111
+ "session_id": session_id,
112
+ "adapter": "openai_agents",
113
+ "item_summary": summarize_items(result),
114
+ "last_agent": getattr(
115
+ getattr(result, "last_agent", None), "name", None
116
+ ),
117
+ },
118
+ )
119
+
120
+ def context(self, session_id: str | None = None) -> dict[str, Any]:
121
+ """Deterministic facts for policy evaluation."""
122
+ _ = session_id
123
+ if self._context_provider is not None:
124
+ return copy.deepcopy(self._context_provider())
125
+ return copy.deepcopy(self._context)
126
+
127
+ def _get_agent(self) -> Agent[Any]:
128
+ if self._resolved_agent is not None:
129
+ return self._resolved_agent
130
+ if self._agent is not None:
131
+ self._resolved_agent = self._agent
132
+ return self._resolved_agent
133
+ if self._agent_factory is not None:
134
+ self._resolved_agent = self._agent_factory()
135
+ return self._resolved_agent
136
+ assert self._agent_ref is not None
137
+ self._resolved_agent = load_agent_from_ref(self._agent_ref)
138
+ return self._resolved_agent
@@ -0,0 +1,144 @@
1
+ """Map OpenAI Agents SDK RunResult items → Mutiny ToolCall / messages.
2
+
3
+ All OpenAI Agents SDK imports stay here (and in adapter.py) — never in Core.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ from typing import Any
10
+
11
+ from agents import ItemHelpers
12
+ from agents.items import MessageOutputItem, ToolCallItem, ToolCallOutputItem
13
+ from agents.result import RunResultBase
14
+
15
+ from mutiny_core.trace.models import ToolCall
16
+
17
+
18
+ def extract_assistant_message(result: RunResultBase) -> str | None:
19
+ """Best-effort assistant text from a run result."""
20
+ if result.final_output is not None:
21
+ out = result.final_output
22
+ if isinstance(out, str):
23
+ return out
24
+ return str(out)
25
+ try:
26
+ text = ItemHelpers.text_message_outputs(list(result.new_items))
27
+ except Exception: # noqa: BLE001
28
+ text = ""
29
+ return text or None
30
+
31
+
32
+ def extract_tool_calls(result: RunResultBase) -> list[ToolCall]:
33
+ """Collect function/tool calls observed during the run."""
34
+ calls: list[ToolCall] = []
35
+ for item in result.new_items:
36
+ if not _is_tool_call_item(item):
37
+ continue
38
+ name = getattr(item, "tool_name", None) or _raw_name(
39
+ getattr(item, "raw_item", None)
40
+ )
41
+ if not name:
42
+ continue
43
+ call_id = getattr(item, "call_id", None) or f"oa-tc-{len(calls) + 1}"
44
+ arguments = _parse_arguments(getattr(item, "raw_item", None))
45
+ calls.append(ToolCall(id=str(call_id), name=name, arguments=arguments))
46
+ return calls
47
+
48
+
49
+ def extract_tool_results(result: RunResultBase) -> list[dict[str, Any]]:
50
+ """Collect tool outputs paired with call ids when available."""
51
+ results: list[dict[str, Any]] = []
52
+ for item in result.new_items:
53
+ if not _is_tool_output_item(item):
54
+ continue
55
+ payload: dict[str, Any] = {
56
+ "call_id": getattr(item, "call_id", None),
57
+ "output": _normalize_output(getattr(item, "output", None)),
58
+ }
59
+ results.append(payload)
60
+ return results
61
+
62
+
63
+ def tools_observable(result: RunResultBase) -> bool:
64
+ """True if this run path can surface tool call evidence.
65
+
66
+ Empty tool_calls is fine (agent may not call tools). We fail only when
67
+ the result object lacks the observation surfaces Mutiny requires.
68
+ """
69
+ return hasattr(result, "new_items")
70
+
71
+
72
+ def summarize_items(result: RunResultBase) -> list[str]:
73
+ """Debug helper: item type names from the run."""
74
+ names: list[str] = []
75
+ for item in result.new_items:
76
+ if _is_tool_call_item(item):
77
+ names.append(f"tool_call:{getattr(item, 'tool_name', None)}")
78
+ elif _is_tool_output_item(item):
79
+ names.append(f"tool_output:{getattr(item, 'call_id', None)}")
80
+ elif isinstance(item, MessageOutputItem) or getattr(
81
+ item, "type", None
82
+ ) == "message_output_item":
83
+ names.append("message")
84
+ else:
85
+ names.append(type(item).__name__)
86
+ return names
87
+
88
+
89
+ def _is_tool_call_item(item: Any) -> bool:
90
+ if isinstance(item, ToolCallItem):
91
+ return True
92
+ return getattr(item, "type", None) == "tool_call_item"
93
+
94
+
95
+ def _is_tool_output_item(item: Any) -> bool:
96
+ if isinstance(item, ToolCallOutputItem):
97
+ return True
98
+ return getattr(item, "type", None) == "tool_call_output_item"
99
+
100
+
101
+ def _raw_name(raw_item: Any) -> str | None:
102
+ if raw_item is None:
103
+ return None
104
+ if isinstance(raw_item, dict):
105
+ name = raw_item.get("name")
106
+ return str(name) if name else None
107
+ name = getattr(raw_item, "name", None)
108
+ return str(name) if name else None
109
+
110
+
111
+ def _parse_arguments(raw_item: Any) -> dict[str, Any]:
112
+ if raw_item is None:
113
+ return {}
114
+ raw_args: Any
115
+ if isinstance(raw_item, dict):
116
+ raw_args = raw_item.get("arguments", {})
117
+ else:
118
+ raw_args = getattr(raw_item, "arguments", {})
119
+ if raw_args is None:
120
+ return {}
121
+ if isinstance(raw_args, dict):
122
+ return dict(raw_args)
123
+ if isinstance(raw_args, str):
124
+ if not raw_args.strip():
125
+ return {}
126
+ try:
127
+ parsed = json.loads(raw_args)
128
+ except json.JSONDecodeError:
129
+ return {"_raw": raw_args}
130
+ if isinstance(parsed, dict):
131
+ return parsed
132
+ return {"value": parsed}
133
+ return {"value": raw_args}
134
+
135
+
136
+ def _normalize_output(output: Any) -> Any:
137
+ if isinstance(output, (dict, list, str, int, float, bool)) or output is None:
138
+ return output
139
+ if hasattr(output, "model_dump"):
140
+ try:
141
+ return output.model_dump()
142
+ except Exception: # noqa: BLE001
143
+ return str(output)
144
+ return str(output)
@@ -0,0 +1,99 @@
1
+ """Load customer agent modules by ``module:attr`` reference."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import sys
7
+ from collections.abc import Callable
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+
12
+ def ensure_project_on_path(project_root: str | Path) -> None:
13
+ """Prepend a customer project root to ``sys.path`` for local imports."""
14
+ root = str(Path(project_root).resolve())
15
+ if root not in sys.path:
16
+ sys.path.insert(0, root)
17
+
18
+
19
+ def load_callable(ref: str) -> Any:
20
+ """Import ``module:attr`` and return the attribute (object or callable)."""
21
+ if ":" not in ref:
22
+ raise ValueError(
23
+ f"invalid ref {ref!r}; expected 'module:attribute' "
24
+ "(e.g. 'agent:support_agent' or 'agent:build_agent')"
25
+ )
26
+ module_name, attr_name = ref.split(":", 1)
27
+ if not module_name or not attr_name:
28
+ raise ValueError(f"invalid ref {ref!r}; module and attribute required")
29
+ module = importlib.import_module(module_name)
30
+ try:
31
+ return getattr(module, attr_name)
32
+ except AttributeError as exc:
33
+ raise AttributeError(
34
+ f"module {module_name!r} has no attribute {attr_name!r}"
35
+ ) from exc
36
+
37
+
38
+ def load_agent_from_ref(ref: str) -> Any:
39
+ """Resolve an OpenAI Agents SDK Agent from ``module:attr``.
40
+
41
+ If the attribute is callable (and not already an Agent-like instance with
42
+ ``name`` + ``tools``), it is called with no arguments.
43
+ """
44
+ obj = load_callable(ref)
45
+ if callable(obj) and not _looks_like_agent(obj):
46
+ obj = obj()
47
+ if not _looks_like_agent(obj):
48
+ raise TypeError(
49
+ f"{ref!r} did not resolve to an OpenAI Agents SDK Agent "
50
+ f"(got {type(obj)!r})"
51
+ )
52
+ return obj
53
+
54
+
55
+ def load_adapter_factory(
56
+ project_root: str | Path,
57
+ *,
58
+ module: str = ".mutiny.adapter",
59
+ attr: str = "create_adapter",
60
+ ) -> Callable[[], Any]:
61
+ """Load ``create_adapter`` from the customer's ``.mutiny/adapter.py``.
62
+
63
+ Supports either package-style ``.mutiny.adapter`` (if ``.mutiny`` is a
64
+ package) or a direct file load of ``.mutiny/adapter.py``.
65
+ """
66
+ root = Path(project_root).resolve()
67
+ ensure_project_on_path(root)
68
+
69
+ adapter_file = root / ".mutiny" / "adapter.py"
70
+ if not adapter_file.exists():
71
+ raise FileNotFoundError(
72
+ f"missing {adapter_file}; run `mutiny init` first"
73
+ )
74
+
75
+ # Load as a unique module from file path so `.mutiny` need not be a package
76
+ spec_name = "mutiny_project_adapter"
77
+ import importlib.util
78
+
79
+ spec = importlib.util.spec_from_file_location(spec_name, adapter_file)
80
+ if spec is None or spec.loader is None:
81
+ raise ImportError(f"cannot load adapter module from {adapter_file}")
82
+ mod = importlib.util.module_from_spec(spec)
83
+ sys.modules[spec_name] = mod
84
+ spec.loader.exec_module(mod)
85
+
86
+ factory = getattr(mod, attr, None)
87
+ if factory is None or not callable(factory):
88
+ raise AttributeError(
89
+ f"{adapter_file} must define callable {attr}() → TargetAdapter"
90
+ )
91
+ return factory # type: ignore[return-value]
92
+
93
+
94
+ def _looks_like_agent(obj: Any) -> bool:
95
+ return (
96
+ hasattr(obj, "name")
97
+ and hasattr(obj, "tools")
98
+ and not isinstance(obj, type)
99
+ )
@@ -0,0 +1,34 @@
1
+ """Hosted / CLI helpers for the sample OpenAI Agents SDK project."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from mutiny_openai_agents.adapter import OpenAIAgentsAdapter
8
+ from mutiny_openai_agents.loader import load_adapter_factory
9
+
10
+
11
+ def sample_project_root(repo_root: Path | None = None) -> Path:
12
+ """Locate ``examples/openai_support_agent`` from the Mutiny repo root."""
13
+ if repo_root is None:
14
+ # packages/mutiny_openai_agents/src/mutiny_openai_agents/sample.py
15
+ repo_root = Path(__file__).resolve().parents[4]
16
+ return repo_root / "examples" / "openai_support_agent"
17
+
18
+
19
+ def make_openai_support_adapter(
20
+ *,
21
+ repo_root: Path | None = None,
22
+ project_root: Path | None = None,
23
+ enforce_refund_policy: bool = False,
24
+ ) -> OpenAIAgentsAdapter:
25
+ """Build adapter from the sample project's ``.mutiny/adapter.py``.
26
+
27
+ Same loader Hosted uses for any customer OpenAI Agents SDK project.
28
+ Always returns ``OpenAIAgentsAdapter`` — never the legacy in-process demo.
29
+ """
30
+ root = project_root or sample_project_root(repo_root)
31
+ if not root.exists():
32
+ raise FileNotFoundError(f"sample project not found: {root}")
33
+ factory = load_adapter_factory(root)
34
+ return factory(enforce_refund_policy=enforce_refund_policy)