ygo74-agent-runtime-langchain 0.8.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.
- ygo74_agent_runtime_langchain-0.8.0/PKG-INFO +14 -0
- ygo74_agent_runtime_langchain-0.8.0/pyproject.toml +31 -0
- ygo74_agent_runtime_langchain-0.8.0/setup.cfg +4 -0
- ygo74_agent_runtime_langchain-0.8.0/ygo74/agent_runtime/integrations/langchain/__init__.py +39 -0
- ygo74_agent_runtime_langchain-0.8.0/ygo74/agent_runtime/integrations/langchain/approval.py +146 -0
- ygo74_agent_runtime_langchain-0.8.0/ygo74/agent_runtime/integrations/langchain/content.py +303 -0
- ygo74_agent_runtime_langchain-0.8.0/ygo74/agent_runtime/integrations/langchain/conversion.py +47 -0
- ygo74_agent_runtime_langchain-0.8.0/ygo74/agent_runtime/integrations/langchain/native.py +475 -0
- ygo74_agent_runtime_langchain-0.8.0/ygo74/agent_runtime/integrations/langchain/py.typed +0 -0
- ygo74_agent_runtime_langchain-0.8.0/ygo74/agent_runtime/integrations/langchain/results.py +188 -0
- ygo74_agent_runtime_langchain-0.8.0/ygo74/agent_runtime/integrations/langchain/streaming.py +534 -0
- ygo74_agent_runtime_langchain-0.8.0/ygo74/agent_runtime/integrations/langchain/worker.py +188 -0
- ygo74_agent_runtime_langchain-0.8.0/ygo74_agent_runtime_langchain.egg-info/PKG-INFO +14 -0
- ygo74_agent_runtime_langchain-0.8.0/ygo74_agent_runtime_langchain.egg-info/SOURCES.txt +15 -0
- ygo74_agent_runtime_langchain-0.8.0/ygo74_agent_runtime_langchain.egg-info/dependency_links.txt +1 -0
- ygo74_agent_runtime_langchain-0.8.0/ygo74_agent_runtime_langchain.egg-info/requires.txt +7 -0
- ygo74_agent_runtime_langchain-0.8.0/ygo74_agent_runtime_langchain.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ygo74-agent-runtime-langchain
|
|
3
|
+
Version: 0.8.0
|
|
4
|
+
Summary: Native LangChain and LangGraph binding for the ygo74 agent runtime
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
Description-Content-Type: text/plain
|
|
7
|
+
Requires-Dist: ygo74-agent-runtime-agents<2,>=0.8.0
|
|
8
|
+
Requires-Dist: langchain-core<1.7,>=1.6.3
|
|
9
|
+
Requires-Dist: langgraph<2,>=1.2
|
|
10
|
+
Provides-Extra: http
|
|
11
|
+
Requires-Dist: ygo74-agent-runtime-agents[http]<2,>=0.8.0; extra == "http"
|
|
12
|
+
Requires-Dist: fastapi>=0.115; extra == "http"
|
|
13
|
+
|
|
14
|
+
Native LangGraph conversation binding and typed LangChain result and streaming adapters for the neutral ygo74 agent runtime. See doc/python/langchain.md in the project repository for installation, supported SDK versions, runnable offline examples, and explicit conversion diagnostics.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ygo74-agent-runtime-langchain"
|
|
7
|
+
version = "0.8.0"
|
|
8
|
+
description = "Native LangChain and LangGraph binding for the ygo74 agent runtime"
|
|
9
|
+
readme = {text = "Native LangGraph conversation binding and typed LangChain result and streaming adapters for the neutral ygo74 agent runtime. See doc/python/langchain.md in the project repository for installation, supported SDK versions, runnable offline examples, and explicit conversion diagnostics.", content-type = "text/plain"}
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"ygo74-agent-runtime-agents>=0.8.0,<2",
|
|
13
|
+
"langchain-core>=1.6.3,<1.7",
|
|
14
|
+
"langgraph>=1.2,<2",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.optional-dependencies]
|
|
18
|
+
http = [
|
|
19
|
+
"ygo74-agent-runtime-agents[http]>=0.8.0,<2",
|
|
20
|
+
"fastapi>=0.115",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[tool.setuptools.packages.find]
|
|
24
|
+
include = [
|
|
25
|
+
"ygo74.agent_runtime.integrations.langchain",
|
|
26
|
+
"ygo74.agent_runtime.integrations.langchain.*",
|
|
27
|
+
]
|
|
28
|
+
namespaces = true
|
|
29
|
+
|
|
30
|
+
[tool.setuptools.package-data]
|
|
31
|
+
"ygo74.agent_runtime.integrations.langchain" = ["py.typed"]
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""LangChain integration with the runtime's neutral output contracts."""
|
|
2
|
+
|
|
3
|
+
from .approval import (
|
|
4
|
+
LangGraphApprovalDecision,
|
|
5
|
+
LangGraphApprovalTranslator,
|
|
6
|
+
LangGraphDecisionType,
|
|
7
|
+
LangGraphPendingApproval,
|
|
8
|
+
)
|
|
9
|
+
from .conversion import (
|
|
10
|
+
ConversionDiagnostic,
|
|
11
|
+
ConversionOutcome,
|
|
12
|
+
ConversionStatus,
|
|
13
|
+
LangChainConversionError,
|
|
14
|
+
)
|
|
15
|
+
from .native import (
|
|
16
|
+
ApprovalResolver,
|
|
17
|
+
LangGraphBindingError,
|
|
18
|
+
LangGraphRunnable,
|
|
19
|
+
LangGraphSession,
|
|
20
|
+
)
|
|
21
|
+
from .results import LangChainResultAdapter
|
|
22
|
+
from .streaming import LangChainStreamAdapter
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"ApprovalResolver",
|
|
26
|
+
"ConversionDiagnostic",
|
|
27
|
+
"ConversionOutcome",
|
|
28
|
+
"ConversionStatus",
|
|
29
|
+
"LangChainConversionError",
|
|
30
|
+
"LangChainResultAdapter",
|
|
31
|
+
"LangChainStreamAdapter",
|
|
32
|
+
"LangGraphApprovalDecision",
|
|
33
|
+
"LangGraphApprovalTranslator",
|
|
34
|
+
"LangGraphBindingError",
|
|
35
|
+
"LangGraphDecisionType",
|
|
36
|
+
"LangGraphPendingApproval",
|
|
37
|
+
"LangGraphRunnable",
|
|
38
|
+
"LangGraphSession",
|
|
39
|
+
]
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""LangGraph's interrupt and resume shapes for the shared approval loop."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping, Sequence
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from enum import StrEnum
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from langgraph.types import Command
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class LangGraphDecisionType(StrEnum):
|
|
14
|
+
"""Answers accepted by LangGraph's human-in-the-loop middleware."""
|
|
15
|
+
|
|
16
|
+
APPROVE = "approve"
|
|
17
|
+
REJECT = "reject"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, slots=True)
|
|
21
|
+
class LangGraphApprovalDecision:
|
|
22
|
+
"""One validated decision for a suspended tool call.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
type: Whether the suspended call is approved or rejected.
|
|
26
|
+
message: Optional rejection explanation shown to the graph.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
type: LangGraphDecisionType
|
|
30
|
+
message: str | None = None
|
|
31
|
+
|
|
32
|
+
@staticmethod
|
|
33
|
+
def for_approval(*, approved: bool) -> LangGraphApprovalDecision:
|
|
34
|
+
"""Create the built-in explicit approval or rejection decision."""
|
|
35
|
+
decision = LangGraphDecisionType.APPROVE if approved else LangGraphDecisionType.REJECT
|
|
36
|
+
message = None if approved else "The user declined this operation."
|
|
37
|
+
return LangGraphApprovalDecision(decision, message)
|
|
38
|
+
|
|
39
|
+
def to_wire(self) -> dict[str, str]:
|
|
40
|
+
"""Return LangGraph's public resume payload for this decision."""
|
|
41
|
+
value = {"type": self.type.value}
|
|
42
|
+
if self.message is not None:
|
|
43
|
+
value["message"] = self.message
|
|
44
|
+
return value
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class LangGraphPendingApproval:
|
|
48
|
+
"""A safely typed view of one LangGraph action request.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
action: Raw interrupt action request at the framework boundary.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(self, action: Mapping[str, Any]) -> None:
|
|
55
|
+
"""Retain the framework request and validate its public shape."""
|
|
56
|
+
name = action.get("name")
|
|
57
|
+
if not isinstance(name, str):
|
|
58
|
+
raise TypeError("LangGraph approval action requires a tool name")
|
|
59
|
+
arguments = action.get("args", {})
|
|
60
|
+
if not isinstance(arguments, Mapping):
|
|
61
|
+
raise TypeError("LangGraph approval arguments must be an object")
|
|
62
|
+
self._tool_name = name
|
|
63
|
+
self._arguments = dict(arguments)
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def tool_name(self) -> str:
|
|
67
|
+
"""Return the requested tool name for trusted domain resolution."""
|
|
68
|
+
return self._tool_name
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def arguments(self) -> Mapping[str, Any]:
|
|
72
|
+
"""Return a defensive copy of proposed arguments to a trusted resolver."""
|
|
73
|
+
return dict(self._arguments)
|
|
74
|
+
|
|
75
|
+
def answer(self, *, approved: bool) -> dict[str, str]:
|
|
76
|
+
"""Build an explicit approve or reject response for this request."""
|
|
77
|
+
decision = LangGraphDecisionType.APPROVE if approved else LangGraphDecisionType.REJECT
|
|
78
|
+
message = None if approved else "The user declined this operation."
|
|
79
|
+
return LangGraphApprovalDecision(decision, message).to_wire()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class LangGraphApprovalTranslator:
|
|
83
|
+
"""Translate LangGraph interrupt actions and resume commands."""
|
|
84
|
+
|
|
85
|
+
def pending(self, result: object) -> tuple[LangGraphPendingApproval, ...]:
|
|
86
|
+
"""Extract every action request, preserving its framework order."""
|
|
87
|
+
interrupts = self._interrupts(result)
|
|
88
|
+
pending: list[LangGraphPendingApproval] = []
|
|
89
|
+
for interrupt in interrupts:
|
|
90
|
+
value = getattr(interrupt, "value", None)
|
|
91
|
+
if not isinstance(value, Mapping):
|
|
92
|
+
continue
|
|
93
|
+
actions = value.get("action_requests", ())
|
|
94
|
+
if not isinstance(actions, Sequence) or isinstance(actions, str | bytes):
|
|
95
|
+
continue
|
|
96
|
+
pending.extend(LangGraphPendingApproval(item) for item in actions if isinstance(item, Mapping))
|
|
97
|
+
if interrupts and not pending:
|
|
98
|
+
raise ValueError("LangGraph returned an interrupt without action requests")
|
|
99
|
+
return tuple(pending)
|
|
100
|
+
|
|
101
|
+
@staticmethod
|
|
102
|
+
def _interrupts(result: object) -> tuple[object, ...]:
|
|
103
|
+
"""Read interrupts from current LangGraph result wrappers or mappings."""
|
|
104
|
+
carried = getattr(result, "interrupts", None)
|
|
105
|
+
if carried:
|
|
106
|
+
return tuple(carried)
|
|
107
|
+
state = getattr(result, "value", result)
|
|
108
|
+
if isinstance(state, Mapping):
|
|
109
|
+
return tuple(state.get("__interrupt__") or ())
|
|
110
|
+
return ()
|
|
111
|
+
|
|
112
|
+
@staticmethod
|
|
113
|
+
def resume_command(
|
|
114
|
+
decisions: Sequence[LangGraphApprovalDecision | Mapping[str, Any]],
|
|
115
|
+
*,
|
|
116
|
+
expected_count: int,
|
|
117
|
+
) -> Command[Any]:
|
|
118
|
+
"""Build a position-matched command after validating every decision."""
|
|
119
|
+
if len(decisions) != expected_count:
|
|
120
|
+
raise ValueError("LangGraph requires one decision per suspended action")
|
|
121
|
+
wire: list[dict[str, str]] = []
|
|
122
|
+
for decision in decisions:
|
|
123
|
+
if isinstance(decision, LangGraphApprovalDecision):
|
|
124
|
+
wire.append(decision.to_wire())
|
|
125
|
+
continue
|
|
126
|
+
if not isinstance(decision, Mapping):
|
|
127
|
+
raise TypeError("LangGraph approval resolver returned an invalid decision")
|
|
128
|
+
raw_type = decision.get("type")
|
|
129
|
+
if not isinstance(raw_type, str):
|
|
130
|
+
raise TypeError("LangGraph approval decision type must be text")
|
|
131
|
+
try:
|
|
132
|
+
decision_type = LangGraphDecisionType(raw_type)
|
|
133
|
+
except (TypeError, ValueError) as error:
|
|
134
|
+
raise ValueError("LangGraph approval resolver returned an unknown decision") from error
|
|
135
|
+
message = decision.get("message")
|
|
136
|
+
if message is not None and not isinstance(message, str):
|
|
137
|
+
raise TypeError("LangGraph decision message must be text")
|
|
138
|
+
wire.append(LangGraphApprovalDecision(decision_type, message).to_wire())
|
|
139
|
+
return Command(resume={"decisions": wire})
|
|
140
|
+
|
|
141
|
+
@staticmethod
|
|
142
|
+
def discard_authorizations(resolver: object) -> None:
|
|
143
|
+
"""Call an optional domain grant cleanup hook when one is provided."""
|
|
144
|
+
discard = getattr(resolver, "discard_authorizations", None)
|
|
145
|
+
if callable(discard):
|
|
146
|
+
discard()
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"""Reuse LangChain's standardized content blocks rather than provider heuristics."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping, Sequence
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
from math import isfinite
|
|
7
|
+
from typing import ClassVar, cast
|
|
8
|
+
|
|
9
|
+
from langchain_core.messages.content import (
|
|
10
|
+
AudioContentBlock,
|
|
11
|
+
ContentBlock,
|
|
12
|
+
ImageContentBlock,
|
|
13
|
+
ReasoningContentBlock,
|
|
14
|
+
ServerToolCall,
|
|
15
|
+
ServerToolResult,
|
|
16
|
+
TextContentBlock,
|
|
17
|
+
ToolCall,
|
|
18
|
+
)
|
|
19
|
+
from ygo74.agent_runtime.domains.contracts.agent_output import (
|
|
20
|
+
AgentContent,
|
|
21
|
+
AudioContent,
|
|
22
|
+
ImageContent,
|
|
23
|
+
ReasoningContent,
|
|
24
|
+
TextContent,
|
|
25
|
+
ToolCallContent,
|
|
26
|
+
ToolExecution,
|
|
27
|
+
ToolResultContent,
|
|
28
|
+
UrlCitation,
|
|
29
|
+
)
|
|
30
|
+
from ygo74.agent_runtime.domains.contracts.json_value import JsonValue
|
|
31
|
+
from ygo74.agent_runtime.domains.contracts.media_content import (
|
|
32
|
+
AudioFormat,
|
|
33
|
+
EncodedMedia,
|
|
34
|
+
MediaSource,
|
|
35
|
+
MediaUri,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
from .conversion import ConversionDiagnostic, LangChainConversionError
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class BlockKind(StrEnum):
|
|
42
|
+
"""Names LangChain content-block variants handled by the message and streaming adapters.
|
|
43
|
+
"""
|
|
44
|
+
TEXT = "text"
|
|
45
|
+
REASONING = "reasoning"
|
|
46
|
+
IMAGE = "image"
|
|
47
|
+
AUDIO = "audio"
|
|
48
|
+
TOOL_CALL = "tool_call"
|
|
49
|
+
TOOL_CALL_CHUNK = "tool_call_chunk"
|
|
50
|
+
SERVER_TOOL_CALL = "server_tool_call"
|
|
51
|
+
SERVER_TOOL_CALL_CHUNK = "server_tool_call_chunk"
|
|
52
|
+
SERVER_TOOL_RESULT = "server_tool_result"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True, slots=True)
|
|
56
|
+
class ContentConversion:
|
|
57
|
+
"""Carries a converted neutral content item together with its diagnostic and support status.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
content (AgentContent | None): The content item being interpreted or projected.
|
|
61
|
+
diagnostic (ConversionDiagnostic | None): Conversion diagnostic explaining an unsupported or filtered value.
|
|
62
|
+
unsupported (bool): Whether conversion encountered content it could not safely represent.
|
|
63
|
+
"""
|
|
64
|
+
content: AgentContent | None = None
|
|
65
|
+
diagnostic: ConversionDiagnostic | None = None
|
|
66
|
+
unsupported: bool = False
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class JsonBoundary:
|
|
70
|
+
"""Protocol for converting framework values into JSON-compatible values or reporting that the value cannot be represented.
|
|
71
|
+
"""
|
|
72
|
+
@classmethod
|
|
73
|
+
def convert(cls, value: object) -> JsonValue:
|
|
74
|
+
"""Convert framework message content into the typed representation consumed by the runtime.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
value (object): The value being converted, checked, or serialized.
|
|
78
|
+
"""
|
|
79
|
+
if value is None or isinstance(value, (str, bool, int)):
|
|
80
|
+
return value
|
|
81
|
+
if isinstance(value, float) and isfinite(value):
|
|
82
|
+
return value
|
|
83
|
+
if isinstance(value, list):
|
|
84
|
+
return [cls.convert(item) for item in cast(list[object], value)]
|
|
85
|
+
if isinstance(value, dict):
|
|
86
|
+
items = cast(dict[object, object], value)
|
|
87
|
+
if all(isinstance(key, str) for key in items):
|
|
88
|
+
return {cast(str, key): cls.convert(item) for key, item in items.items()}
|
|
89
|
+
raise LangChainConversionError("Native value is not a finite JSON value.")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class LangChainContentAdapter:
|
|
93
|
+
"""Adapt framework values to or from the shared typed output contract while preserving supported metadata.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
expose_reasoning (bool): Whether reasoning content may be included in client-visible output.
|
|
97
|
+
tool_execution (ToolExecution): Whether tool calls are internal or delegated to the client.
|
|
98
|
+
"""
|
|
99
|
+
_audio_formats: ClassVar[dict[str, AudioFormat]] = {
|
|
100
|
+
"audio/wav": AudioFormat.WAV,
|
|
101
|
+
"audio/x-wav": AudioFormat.WAV,
|
|
102
|
+
"audio/mpeg": AudioFormat.MP3,
|
|
103
|
+
"audio/mp3": AudioFormat.MP3,
|
|
104
|
+
"audio/flac": AudioFormat.FLAC,
|
|
105
|
+
"audio/opus": AudioFormat.OPUS,
|
|
106
|
+
"audio/aac": AudioFormat.AAC,
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
def __init__(self, *, expose_reasoning: bool, tool_execution: ToolExecution) -> None:
|
|
110
|
+
"""Initialize the instance framework message content with supplied collaborators and configuration.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
expose_reasoning (bool): Whether reasoning content may be included in client-visible output.
|
|
114
|
+
tool_execution (ToolExecution): Whether tool calls are internal or delegated to the client.
|
|
115
|
+
"""
|
|
116
|
+
self.expose_reasoning = expose_reasoning
|
|
117
|
+
self.tool_execution = tool_execution
|
|
118
|
+
|
|
119
|
+
def convert(
|
|
120
|
+
self, block: ContentBlock, *, fallback_id: str,
|
|
121
|
+
tool_names: Mapping[str, str] | None = None,
|
|
122
|
+
) -> ContentConversion:
|
|
123
|
+
"""Convert framework message content into the typed representation consumed by the runtime.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
block (ContentBlock): Framework content block being converted into the neutral contract.
|
|
127
|
+
fallback_id (str): Deterministic content identity used when the SDK block has no ID.
|
|
128
|
+
tool_names (Mapping[str, str] | None): Lookup from tool-call IDs to names in the current SDK message.
|
|
129
|
+
"""
|
|
130
|
+
kind = block["type"]
|
|
131
|
+
if kind == BlockKind.TEXT.value:
|
|
132
|
+
return self._text(cast(TextContentBlock, block))
|
|
133
|
+
if kind == BlockKind.REASONING.value:
|
|
134
|
+
return self._reasoning(cast(ReasoningContentBlock, block))
|
|
135
|
+
if kind == BlockKind.IMAGE.value:
|
|
136
|
+
return self._image(cast(ImageContentBlock, block))
|
|
137
|
+
if kind == BlockKind.AUDIO.value:
|
|
138
|
+
return self._audio(cast(AudioContentBlock, block), fallback_id)
|
|
139
|
+
if kind in (BlockKind.TOOL_CALL.value, BlockKind.SERVER_TOOL_CALL.value):
|
|
140
|
+
return self._tool_call(cast(ToolCall | ServerToolCall, block))
|
|
141
|
+
if kind == BlockKind.SERVER_TOOL_RESULT.value:
|
|
142
|
+
return self._server_tool_result(cast(ServerToolResult, block), tool_names)
|
|
143
|
+
return self.unsupported("unsupported_content", "The SDK content block has no neutral output mapping.")
|
|
144
|
+
|
|
145
|
+
@staticmethod
|
|
146
|
+
def unsupported(code: str, reason: str) -> ContentConversion:
|
|
147
|
+
"""Create a conversion outcome that marks a native block unsupported and includes a safe diagnostic.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
code (str): Stable error or diagnostic code returned to the caller.
|
|
151
|
+
reason (str): Reason code or message associated with this decision or failure.
|
|
152
|
+
"""
|
|
153
|
+
return ContentConversion(diagnostic=ConversionDiagnostic(code, reason), unsupported=True)
|
|
154
|
+
|
|
155
|
+
@staticmethod
|
|
156
|
+
def _text(block: TextContentBlock) -> ContentConversion:
|
|
157
|
+
"""Convert a text block and supported citations while reporting unsupported annotations.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
block (TextContentBlock): Framework content block being converted into the neutral contract.
|
|
161
|
+
"""
|
|
162
|
+
# Preserve supported citations while keeping the text block; mark unsupported annotation variants so callers receive a diagnostic instead of silent data loss.
|
|
163
|
+
annotations: list[UrlCitation] = []
|
|
164
|
+
unsupported = False
|
|
165
|
+
for annotation in block.get("annotations", []):
|
|
166
|
+
if annotation["type"] != "citation":
|
|
167
|
+
unsupported = True
|
|
168
|
+
continue
|
|
169
|
+
url, title = annotation.get("url"), annotation.get("title")
|
|
170
|
+
start, end = annotation.get("start_index"), annotation.get("end_index")
|
|
171
|
+
if url is None or title is None or start is None or end is None:
|
|
172
|
+
unsupported = True
|
|
173
|
+
continue
|
|
174
|
+
annotations.append(UrlCitation(url, title, start, end))
|
|
175
|
+
diagnostic = ConversionDiagnostic(
|
|
176
|
+
"unsupported_citation", "A text annotation lacks the genuine URL, title or offsets required by the neutral citation.",
|
|
177
|
+
) if unsupported else None
|
|
178
|
+
return ContentConversion(TextContent(block["text"], tuple(annotations)), diagnostic, unsupported)
|
|
179
|
+
|
|
180
|
+
def _reasoning(self, block: ReasoningContentBlock) -> ContentConversion:
|
|
181
|
+
"""Convert a reasoning block and apply the configured reasoning-exposure policy.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
block (ReasoningContentBlock): Framework content block being converted into the neutral contract.
|
|
185
|
+
"""
|
|
186
|
+
if not self.expose_reasoning:
|
|
187
|
+
return ContentConversion(diagnostic=ConversionDiagnostic(
|
|
188
|
+
"reasoning_not_selected", "Reasoning requires explicit developer selection.",
|
|
189
|
+
))
|
|
190
|
+
if "reasoning" not in block:
|
|
191
|
+
return self.unsupported("protected_reasoning", "The SDK block exposes no publishable reasoning text.")
|
|
192
|
+
return ContentConversion(ReasoningContent(block["reasoning"], exposable=True))
|
|
193
|
+
|
|
194
|
+
def _tool_call(self, block: ToolCall | ServerToolCall) -> ContentConversion:
|
|
195
|
+
"""Convert a LangChain tool call while preserving its call ID and JSON arguments.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
block (ToolCall | ServerToolCall): Framework content block being converted into the neutral contract.
|
|
199
|
+
"""
|
|
200
|
+
call_id = block["id"]
|
|
201
|
+
if not call_id or not call_id.strip():
|
|
202
|
+
return self.unsupported("missing_tool_call_id", "A tool call must have an SDK correlation identity.")
|
|
203
|
+
if not block["name"].strip():
|
|
204
|
+
return self.unsupported("missing_tool_name", "A tool call requires a genuine nonempty SDK name.")
|
|
205
|
+
execution = ToolExecution.INTERNAL if block["type"] == BlockKind.SERVER_TOOL_CALL.value else self.tool_execution
|
|
206
|
+
return ContentConversion(ToolCallContent(call_id, block["name"], JsonBoundary.convert(block["args"]), execution))
|
|
207
|
+
|
|
208
|
+
@classmethod
|
|
209
|
+
def tool_names(cls, blocks: Sequence[ContentBlock]) -> dict[str, str]:
|
|
210
|
+
"""Index tool call IDs and names so related result blocks remain correlated.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
blocks (Sequence[ContentBlock]): Ordered framework content blocks being converted.
|
|
214
|
+
"""
|
|
215
|
+
# Build the call-ID-to-name index before converting blocks so later tool results can retain the name associated with their invocation.
|
|
216
|
+
names: dict[str, str] = {}
|
|
217
|
+
for block in blocks:
|
|
218
|
+
if block["type"] not in (BlockKind.TOOL_CALL.value, BlockKind.SERVER_TOOL_CALL.value):
|
|
219
|
+
continue
|
|
220
|
+
call_id, name = block["id"], block["name"]
|
|
221
|
+
if call_id and call_id.strip() and name.strip():
|
|
222
|
+
cls.register_tool_name(names, call_id, name)
|
|
223
|
+
return names
|
|
224
|
+
|
|
225
|
+
@staticmethod
|
|
226
|
+
def register_tool_name(names: dict[str, str], call_id: str, name: str) -> None:
|
|
227
|
+
"""Register tool name after checking identity and uniqueness constraints.
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
names (dict[str, str]): Tool call IDs and names used to correlate related blocks.
|
|
231
|
+
call_id (str): Stable tool invocation identity used to correlate its result.
|
|
232
|
+
name (str): The name used to locate or label the value.
|
|
233
|
+
"""
|
|
234
|
+
previous = names.setdefault(call_id, name)
|
|
235
|
+
if previous != name:
|
|
236
|
+
raise LangChainConversionError("SDK tool name changed for a correlated call identity.")
|
|
237
|
+
|
|
238
|
+
def _server_tool_result(
|
|
239
|
+
self, block: ServerToolResult, tool_names: Mapping[str, str] | None,
|
|
240
|
+
) -> ContentConversion:
|
|
241
|
+
"""Convert a server tool result as an internal observation, not a client-delegated call.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
block (ServerToolResult): Framework content block being converted into the neutral contract.
|
|
245
|
+
tool_names (Mapping[str, str] | None): Lookup from tool-call IDs to names in the current SDK message.
|
|
246
|
+
"""
|
|
247
|
+
call_id = block["tool_call_id"]
|
|
248
|
+
if not call_id.strip():
|
|
249
|
+
return self.unsupported("missing_tool_call_id", "A tool result must have an SDK correlation identity.")
|
|
250
|
+
name = tool_names.get(call_id) if tool_names is not None else None
|
|
251
|
+
if name is None or not name.strip():
|
|
252
|
+
return self.unsupported("missing_tool_name", "A server tool result requires a genuine name from its correlated SDK call.")
|
|
253
|
+
content = ToolResultContent(
|
|
254
|
+
call_id, name, JsonBoundary.convert(block.get("output")), ToolExecution.INTERNAL,
|
|
255
|
+
)
|
|
256
|
+
if block["status"] == "error":
|
|
257
|
+
return ContentConversion(content, ConversionDiagnostic(
|
|
258
|
+
"tool_result_status_unrepresentable", "The neutral tool result has no per-tool failure status.",
|
|
259
|
+
), unsupported=True)
|
|
260
|
+
return ContentConversion(content)
|
|
261
|
+
|
|
262
|
+
def _image(self, block: ImageContentBlock) -> ContentConversion:
|
|
263
|
+
"""Convert an image block while preserving its URI or encoded source and MIME type.
|
|
264
|
+
|
|
265
|
+
Args:
|
|
266
|
+
block (ImageContentBlock): Framework content block being converted into the neutral contract.
|
|
267
|
+
"""
|
|
268
|
+
source = self._source(block)
|
|
269
|
+
if source is None:
|
|
270
|
+
return self.unsupported("unsupported_media_source", "Image output requires a URI or base64 and explicit MIME.")
|
|
271
|
+
return ContentConversion(ImageContent(source))
|
|
272
|
+
|
|
273
|
+
def _audio(self, block: AudioContentBlock, fallback_id: str) -> ContentConversion:
|
|
274
|
+
"""Convert an audio block while preserving source, format, and available transcript metadata.
|
|
275
|
+
|
|
276
|
+
Args:
|
|
277
|
+
block (AudioContentBlock): Framework content block being converted into the neutral contract.
|
|
278
|
+
fallback_id (str): Deterministic content identity used when the SDK block has no ID.
|
|
279
|
+
"""
|
|
280
|
+
source = self._source(block)
|
|
281
|
+
audio_format = self._audio_formats.get(block.get("mime_type", ""))
|
|
282
|
+
if source is None or audio_format is None:
|
|
283
|
+
return self.unsupported("unsupported_audio_format", "Audio output requires a source and a known explicit MIME.")
|
|
284
|
+
return ContentConversion(AudioContent(source, block.get("id") or fallback_id, format=audio_format))
|
|
285
|
+
|
|
286
|
+
@staticmethod
|
|
287
|
+
def _source(block: ImageContentBlock | AudioContentBlock) -> MediaSource | None:
|
|
288
|
+
"""Read a media source from a framework block without retrieving or transcoding it.
|
|
289
|
+
|
|
290
|
+
Args:
|
|
291
|
+
block (ImageContentBlock | AudioContentBlock): Framework content block being converted into the neutral contract.
|
|
292
|
+
"""
|
|
293
|
+
mime = block.get("mime_type")
|
|
294
|
+
if not mime:
|
|
295
|
+
return None
|
|
296
|
+
uri, encoded = block.get("url"), block.get("base64")
|
|
297
|
+
if uri and encoded:
|
|
298
|
+
raise LangChainConversionError("Native media has both URI and encoded sources.")
|
|
299
|
+
if uri:
|
|
300
|
+
return MediaUri(uri, mime)
|
|
301
|
+
if encoded is not None:
|
|
302
|
+
return EncodedMedia(encoded, mime)
|
|
303
|
+
return None
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Inspectable conversion decisions, independent of endpoint protocols."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from enum import StrEnum
|
|
5
|
+
|
|
6
|
+
from ygo74.agent_runtime.domains.contracts.agent_output import AgentOutput
|
|
7
|
+
from ygo74.agent_runtime.domains.contracts.stream_events import AgentStreamEvent
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ConversionStatus(StrEnum):
|
|
11
|
+
"""Names whether framework conversion succeeded, partially succeeded with unsupported content, or excluded the value.
|
|
12
|
+
"""
|
|
13
|
+
CONVERTED = "converted"
|
|
14
|
+
EXCLUDED = "excluded"
|
|
15
|
+
UNSUPPORTED = "unsupported"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True, slots=True)
|
|
19
|
+
class ConversionDiagnostic:
|
|
20
|
+
"""Safe, non-payload diagnostic describing framework content that could not be represented completely.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
code (str): Stable error or diagnostic code returned to the caller.
|
|
24
|
+
reason (str): Reason code or message associated with this decision or failure.
|
|
25
|
+
"""
|
|
26
|
+
code: str
|
|
27
|
+
reason: str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class ConversionOutcome:
|
|
32
|
+
"""Unsupported portions do not discard successfully converted portions.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
status (ConversionStatus): Success, incomplete, or failed outcome for the operation.
|
|
36
|
+
events (tuple[AgentStreamEvent, ...]): The ordered typed events that describe the streamed response.
|
|
37
|
+
output (AgentOutput | None): Typed agent output being validated, filtered, or projected.
|
|
38
|
+
diagnostics (tuple[ConversionDiagnostic, ...]): Safe conversion diagnostics collected while processing native content.
|
|
39
|
+
"""
|
|
40
|
+
status: ConversionStatus
|
|
41
|
+
events: tuple[AgentStreamEvent, ...] = ()
|
|
42
|
+
output: AgentOutput | None = None
|
|
43
|
+
diagnostics: tuple[ConversionDiagnostic, ...] = ()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class LangChainConversionError(ValueError):
|
|
47
|
+
"""An invalid native value or stream lifecycle, not a support limitation."""
|