fred-runtime 2.0.10__tar.gz → 2.0.11__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.
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/PKG-INFO +1 -1
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/app/agent_app.py +15 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/graph/graph_runtime.py +141 -9
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime.egg-info/PKG-INFO +1 -1
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime.egg-info/SOURCES.txt +1 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/pyproject.toml +1 -1
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/tests/test_agent_app.py +93 -0
- fred_runtime-2.0.11/tests/test_graph_runtime_invoke_agent.py +154 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/README.md +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/__init__.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/app/__init__.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/app/_catalogs.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/app/config.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/app/config_loader.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/app/container.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/app/context.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/app/dependencies.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/app/mcp_config.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/app/observability_factory.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/app/openai_compat_router.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/cli/__init__.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/cli/completion.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/cli/entrypoint.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/cli/history_display.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/cli/kpi_display.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/cli/pod_client.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/cli/repl.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/cli/repl_helpers.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/cli/url_helpers.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/client.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/common/__init__.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/common/context_aware_tool.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/common/kf_base_client.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/common/kf_fast_text_client.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/common/kf_http_client.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/common/kf_logs_client.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/common/kf_markdown_media_client.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/common/kf_vectorsearch_client.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/common/kf_workspace_client.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/common/mcp_interceptors.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/common/mcp_runtime.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/common/mcp_toolkit.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/common/mcp_utils.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/common/structures.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/common/token_expiry.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/common/tool_node_utils.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/deep/__init__.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/deep/deep_runtime.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/eval/__init__.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/eval/collector.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/graph/__init__.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/integrations/__init__.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/integrations/v2_runtime/__init__.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/integrations/v2_runtime/adapters.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/model_routing/__init__.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/model_routing/catalog.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/model_routing/contracts.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/model_routing/provider.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/model_routing/resolver.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/react/__init__.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/react/react_langchain_adapter.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/react/react_message_codec.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/react/react_model_adapter.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/react/react_prompting.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/react/react_runtime.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/react/react_stream_adapter.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/react/react_tool_binding.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/react/react_tool_loop.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/react/react_tool_rendering.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/react/react_tool_resolution.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/react/react_tool_utils.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/react/react_tracing.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/runtime_context.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/runtime_support/__init__.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/runtime_support/checkpoints.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/runtime_support/model_metadata.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/runtime_support/request_context_helpers.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/runtime_support/sql_checkpointer.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/runtime_support/user_token_refresher.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/support/__init__.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/support/filesystem_context.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/support/tool_approval.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/support/tool_loop.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime.egg-info/dependency_links.txt +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime.egg-info/entry_points.txt +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime.egg-info/requires.txt +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime.egg-info/top_level.txt +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/setup.cfg +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/tests/test_client.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/tests/test_config_loader.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/tests/test_context.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/tests/test_context_aware_tool.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/tests/test_conversational_memory.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/tests/test_eval_collector.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/tests/test_eval_trace.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/tests/test_graph_runtime_observability.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/tests/test_history.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/tests/test_kf_workspace_client.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/tests/test_kpi_display.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/tests/test_mcp_config.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/tests/test_model_routing.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/tests/test_openai_compat_router.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/tests/test_pod_client.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/tests/test_repl_helpers.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/tests/test_smoke.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/tests/test_token_expiry.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/tests/test_url_helpers.py +0 -0
- {fred_runtime-2.0.10 → fred_runtime-2.0.11}/tests/test_user_token_refresher.py +0 -0
|
@@ -563,6 +563,21 @@ class LocalRegistryAgentInvoker(AgentInvokerPort):
|
|
|
563
563
|
|
|
564
564
|
context_dict = request.context.model_dump(mode="json")
|
|
565
565
|
context_dict.setdefault("execution_action", ExecutionGrantAction.EXECUTE.value)
|
|
566
|
+
# RFC AGENT-INVOKE: apply the caller's per-call scope onto the callee's
|
|
567
|
+
# retrieval context. These keys are read back when the callee's
|
|
568
|
+
# RuntimeContext is built, so they narrow its document/library/search world.
|
|
569
|
+
# Scope narrows only; the callee still runs under the delegated identity.
|
|
570
|
+
if request.scope is not None:
|
|
571
|
+
if request.scope.document_uids is not None:
|
|
572
|
+
context_dict["selected_document_uids"] = list(
|
|
573
|
+
request.scope.document_uids
|
|
574
|
+
)
|
|
575
|
+
if request.scope.library_ids is not None:
|
|
576
|
+
context_dict["selected_document_libraries_ids"] = list(
|
|
577
|
+
request.scope.library_ids
|
|
578
|
+
)
|
|
579
|
+
if request.scope.search_policy is not None:
|
|
580
|
+
context_dict["search_policy"] = request.scope.search_policy
|
|
566
581
|
execute_request = _AgentExecuteRequest.model_construct(
|
|
567
582
|
agent_id=request.agent_id,
|
|
568
583
|
agent_instance_id=None,
|
|
@@ -36,7 +36,7 @@ import uuid
|
|
|
36
36
|
from collections.abc import AsyncIterator, Awaitable, Callable, Mapping
|
|
37
37
|
from contextlib import asynccontextmanager, nullcontext
|
|
38
38
|
from dataclasses import dataclass, field
|
|
39
|
-
from typing import Protocol, cast
|
|
39
|
+
from typing import Any, Protocol, cast
|
|
40
40
|
|
|
41
41
|
from fred_core.portable import MetricsProvider
|
|
42
42
|
from fred_sdk.contracts.context import (
|
|
@@ -47,6 +47,7 @@ from fred_sdk.contracts.context import (
|
|
|
47
47
|
BoundRuntimeContext,
|
|
48
48
|
ConversationTurn,
|
|
49
49
|
FetchedResource,
|
|
50
|
+
InvocationScope,
|
|
50
51
|
PublishedArtifact,
|
|
51
52
|
ResourceFetchRequest,
|
|
52
53
|
ResourceScope,
|
|
@@ -90,7 +91,7 @@ from langchain_core.messages import BaseMessage
|
|
|
90
91
|
from langchain_core.runnables import RunnableConfig
|
|
91
92
|
from langchain_core.tools import BaseTool
|
|
92
93
|
from langgraph.checkpoint.base import Checkpoint, CheckpointMetadata, empty_checkpoint
|
|
93
|
-
from pydantic import BaseModel
|
|
94
|
+
from pydantic import BaseModel, ValidationError
|
|
94
95
|
|
|
95
96
|
from fred_runtime.runtime_support.checkpoints import (
|
|
96
97
|
AsyncCheckpointReader,
|
|
@@ -100,6 +101,84 @@ from fred_runtime.runtime_support.model_metadata import runtime_metadata_from_me
|
|
|
100
101
|
|
|
101
102
|
logger = logging.getLogger(__name__)
|
|
102
103
|
|
|
104
|
+
# RFC AGENT-INVOKE: typed agent invocation — how many times invoke_agent re-asks a
|
|
105
|
+
# callee for a valid JSON object before giving up and returning structured=None.
|
|
106
|
+
_STRUCTURED_OUTPUT_MAX_ATTEMPTS = 2
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _structured_output_instruction(schema: dict[str, Any]) -> str:
|
|
110
|
+
"""Instruction appended to the callee message asking for schema-conformant JSON."""
|
|
111
|
+
return (
|
|
112
|
+
"\n\nIMPORTANT: Respond with ONLY a single valid JSON object that conforms to "
|
|
113
|
+
"this JSON Schema. No prose, no explanation, no markdown code fences:\n"
|
|
114
|
+
+ json.dumps(schema, ensure_ascii=False)
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _try_json_object(text: str) -> dict[str, Any] | None:
|
|
119
|
+
"""Parse ``text`` as JSON, returning it only if it is a JSON object."""
|
|
120
|
+
try:
|
|
121
|
+
parsed = json.loads(text)
|
|
122
|
+
except (ValueError, TypeError):
|
|
123
|
+
return None
|
|
124
|
+
return parsed if isinstance(parsed, dict) else None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _extract_json_object(text: str) -> dict[str, Any] | None:
|
|
128
|
+
"""Best-effort extraction of a single JSON object from an agent's free text.
|
|
129
|
+
|
|
130
|
+
Tries, in order: the whole string, a fenced ``` block, then the first balanced
|
|
131
|
+
``{...}`` span. Returns ``None`` if nothing parses to an object.
|
|
132
|
+
"""
|
|
133
|
+
if not text:
|
|
134
|
+
return None
|
|
135
|
+
candidate = text.strip()
|
|
136
|
+
obj = _try_json_object(candidate)
|
|
137
|
+
if obj is not None:
|
|
138
|
+
return obj
|
|
139
|
+
|
|
140
|
+
fence = candidate.find("```")
|
|
141
|
+
if fence != -1:
|
|
142
|
+
rest = candidate[fence + 3 :]
|
|
143
|
+
if rest[:4].lower() == "json":
|
|
144
|
+
rest = rest[4:]
|
|
145
|
+
end = rest.find("```")
|
|
146
|
+
if end != -1:
|
|
147
|
+
obj = _try_json_object(rest[:end].strip())
|
|
148
|
+
if obj is not None:
|
|
149
|
+
return obj
|
|
150
|
+
|
|
151
|
+
start = candidate.find("{")
|
|
152
|
+
while start != -1:
|
|
153
|
+
depth = 0
|
|
154
|
+
for index in range(start, len(candidate)):
|
|
155
|
+
char = candidate[index]
|
|
156
|
+
if char == "{":
|
|
157
|
+
depth += 1
|
|
158
|
+
elif char == "}":
|
|
159
|
+
depth -= 1
|
|
160
|
+
if depth == 0:
|
|
161
|
+
obj = _try_json_object(candidate[start : index + 1])
|
|
162
|
+
if obj is not None:
|
|
163
|
+
return obj
|
|
164
|
+
break
|
|
165
|
+
start = candidate.find("{", start + 1)
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _coerce_structured_payload(
|
|
170
|
+
content: str, output_schema: type[BaseModel]
|
|
171
|
+
) -> dict[str, Any] | None:
|
|
172
|
+
"""Extract + validate a callee's text into ``output_schema``; ``None`` on failure."""
|
|
173
|
+
parsed = _extract_json_object(content)
|
|
174
|
+
if parsed is None:
|
|
175
|
+
return None
|
|
176
|
+
try:
|
|
177
|
+
return output_schema.model_validate(parsed).model_dump(mode="json")
|
|
178
|
+
except ValidationError:
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
|
|
103
182
|
GraphNodeHandler = Callable[
|
|
104
183
|
[BaseModel, GraphNodeContext], GraphNodeResult | Awaitable[GraphNodeResult]
|
|
105
184
|
]
|
|
@@ -683,7 +762,16 @@ class _GraphNodeExecutionContext:
|
|
|
683
762
|
message: str,
|
|
684
763
|
*,
|
|
685
764
|
prior_turns: tuple[ConversationTurn, ...] = (),
|
|
765
|
+
output_schema: type[BaseModel] | None = None,
|
|
766
|
+
scope: InvocationScope | None = None,
|
|
686
767
|
) -> AgentInvocationResult:
|
|
768
|
+
"""Invoke another registered agent for one turn (RFC AGENT-INVOKE).
|
|
769
|
+
|
|
770
|
+
When ``output_schema`` is given the callee is asked for a JSON object of that
|
|
771
|
+
shape; the validated payload is attached to ``result.structured`` (with a
|
|
772
|
+
bounded retry, ``None`` if it could not be coerced). When ``scope`` is given,
|
|
773
|
+
the callee's retrieval world is narrowed for this call only.
|
|
774
|
+
"""
|
|
687
775
|
agent_invoker = self.services.agent_invoker
|
|
688
776
|
if agent_invoker is None:
|
|
689
777
|
raise RuntimeError(
|
|
@@ -694,6 +782,13 @@ class _GraphNodeExecutionContext:
|
|
|
694
782
|
|
|
695
783
|
self.emit_status("invoke_agent", detail=agent_id)
|
|
696
784
|
|
|
785
|
+
schema_dict = (
|
|
786
|
+
output_schema.model_json_schema() if output_schema is not None else None
|
|
787
|
+
)
|
|
788
|
+
max_attempts = (
|
|
789
|
+
_STRUCTURED_OUTPUT_MAX_ATTEMPTS if output_schema is not None else 1
|
|
790
|
+
)
|
|
791
|
+
|
|
697
792
|
span = _start_runtime_span(
|
|
698
793
|
services=self.services,
|
|
699
794
|
binding=self.binding,
|
|
@@ -704,6 +799,7 @@ class _GraphNodeExecutionContext:
|
|
|
704
799
|
"target_agent_id": agent_id,
|
|
705
800
|
},
|
|
706
801
|
)
|
|
802
|
+
result: AgentInvocationResult | None = None
|
|
707
803
|
try:
|
|
708
804
|
with _graph_phase_timer(
|
|
709
805
|
metrics=self.services.metrics,
|
|
@@ -716,14 +812,49 @@ class _GraphNodeExecutionContext:
|
|
|
716
812
|
"target_agent_id": agent_id,
|
|
717
813
|
},
|
|
718
814
|
) as kpi_dims:
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
815
|
+
for attempt in range(max_attempts):
|
|
816
|
+
effective_message = message
|
|
817
|
+
if schema_dict is not None:
|
|
818
|
+
effective_message = message + _structured_output_instruction(
|
|
819
|
+
schema_dict
|
|
820
|
+
)
|
|
821
|
+
if attempt > 0:
|
|
822
|
+
effective_message = (
|
|
823
|
+
"Your previous response was not a valid JSON object. "
|
|
824
|
+
+ effective_message
|
|
825
|
+
)
|
|
826
|
+
result = await agent_invoker.invoke(
|
|
827
|
+
AgentInvocationRequest(
|
|
828
|
+
agent_id=agent_id,
|
|
829
|
+
message=effective_message,
|
|
830
|
+
context=self.binding.portable_context,
|
|
831
|
+
prior_turns=prior_turns,
|
|
832
|
+
scope=scope,
|
|
833
|
+
output_schema=schema_dict,
|
|
834
|
+
)
|
|
835
|
+
)
|
|
836
|
+
if output_schema is None or result.is_error:
|
|
837
|
+
break
|
|
838
|
+
structured = _coerce_structured_payload(
|
|
839
|
+
result.content, output_schema
|
|
840
|
+
)
|
|
841
|
+
if structured is not None:
|
|
842
|
+
result = result.model_copy(update={"structured": structured})
|
|
843
|
+
break
|
|
844
|
+
|
|
845
|
+
assert result is not None
|
|
846
|
+
if (
|
|
847
|
+
output_schema is not None
|
|
848
|
+
and not result.is_error
|
|
849
|
+
and result.structured is None
|
|
850
|
+
):
|
|
851
|
+
logger.warning(
|
|
852
|
+
"invoke_agent(%s): could not coerce output to %s after %d "
|
|
853
|
+
"attempt(s); returning structured=None",
|
|
854
|
+
agent_id,
|
|
855
|
+
output_schema.__name__,
|
|
856
|
+
max_attempts,
|
|
725
857
|
)
|
|
726
|
-
)
|
|
727
858
|
if result.is_error:
|
|
728
859
|
kpi_dims["status"] = "error"
|
|
729
860
|
if span is not None:
|
|
@@ -737,6 +868,7 @@ class _GraphNodeExecutionContext:
|
|
|
737
868
|
finally:
|
|
738
869
|
if span is not None:
|
|
739
870
|
span.end()
|
|
871
|
+
assert result is not None
|
|
740
872
|
return result
|
|
741
873
|
|
|
742
874
|
async def publish_text(
|
|
@@ -90,6 +90,7 @@ tests/test_context_aware_tool.py
|
|
|
90
90
|
tests/test_conversational_memory.py
|
|
91
91
|
tests/test_eval_collector.py
|
|
92
92
|
tests/test_eval_trace.py
|
|
93
|
+
tests/test_graph_runtime_invoke_agent.py
|
|
93
94
|
tests/test_graph_runtime_observability.py
|
|
94
95
|
tests/test_history.py
|
|
95
96
|
tests/test_kf_workspace_client.py
|
|
@@ -39,6 +39,7 @@ from fred_sdk.authoring import ReActAgent, tool
|
|
|
39
39
|
from fred_sdk.authoring.api import ToolContext
|
|
40
40
|
from fred_sdk.contracts.context import (
|
|
41
41
|
AgentInvocationRequest,
|
|
42
|
+
InvocationScope,
|
|
42
43
|
PortableContext,
|
|
43
44
|
PortableEnvironment,
|
|
44
45
|
)
|
|
@@ -994,6 +995,98 @@ def test_local_registry_invoker_reuses_runtime_execute_projection(monkeypatch) -
|
|
|
994
995
|
assert context["execution_action"] == "execute"
|
|
995
996
|
|
|
996
997
|
|
|
998
|
+
def test_local_registry_invoker_applies_invocation_scope(monkeypatch) -> None:
|
|
999
|
+
"""
|
|
1000
|
+
RFC AGENT-INVOKE: a per-call ``InvocationScope`` narrows the callee's retrieval.
|
|
1001
|
+
|
|
1002
|
+
Why this exists:
|
|
1003
|
+
- typed/scoped agent invocation lets one agent restrict the callee to specific
|
|
1004
|
+
documents/libraries; the scope must reach the callee's RuntimeContext, which is
|
|
1005
|
+
built from the context dict the invoker forwards
|
|
1006
|
+
- this proves the scope fields land on that context dict (and only when given)
|
|
1007
|
+
|
|
1008
|
+
How to use it:
|
|
1009
|
+
- run in the default offline `fred-runtime` test suite
|
|
1010
|
+
"""
|
|
1011
|
+
|
|
1012
|
+
seen: dict[str, object] = {}
|
|
1013
|
+
|
|
1014
|
+
async def _fake_iterate_runtime_event_payloads(
|
|
1015
|
+
definition,
|
|
1016
|
+
request,
|
|
1017
|
+
access_token=None,
|
|
1018
|
+
*,
|
|
1019
|
+
team_id=None,
|
|
1020
|
+
registry=None,
|
|
1021
|
+
exchange_id=None,
|
|
1022
|
+
):
|
|
1023
|
+
_ = (definition, access_token, team_id, registry, exchange_id)
|
|
1024
|
+
seen["context"] = dict(request.context or {})
|
|
1025
|
+
yield {"kind": "final", "sequence": 0, "content": "ok"}
|
|
1026
|
+
|
|
1027
|
+
monkeypatch.setattr(
|
|
1028
|
+
agent_app_module,
|
|
1029
|
+
"_iterate_runtime_event_payloads",
|
|
1030
|
+
_fake_iterate_runtime_event_payloads,
|
|
1031
|
+
)
|
|
1032
|
+
|
|
1033
|
+
definition = _EchoAgent()
|
|
1034
|
+
invoker = agent_app_module.LocalRegistryAgentInvoker(
|
|
1035
|
+
registry={definition.agent_id: definition},
|
|
1036
|
+
access_token="token-1",
|
|
1037
|
+
)
|
|
1038
|
+
|
|
1039
|
+
def _portable() -> PortableContext:
|
|
1040
|
+
return PortableContext(
|
|
1041
|
+
request_id="req-1",
|
|
1042
|
+
correlation_id="corr-1",
|
|
1043
|
+
actor="alice",
|
|
1044
|
+
tenant="tenant-a",
|
|
1045
|
+
environment=PortableEnvironment.DEV,
|
|
1046
|
+
trace_id="trace-1",
|
|
1047
|
+
session_id="session-1",
|
|
1048
|
+
user_id="alice",
|
|
1049
|
+
team_id="fredlab",
|
|
1050
|
+
)
|
|
1051
|
+
|
|
1052
|
+
# With scope → narrowing fields land on the forwarded context dict.
|
|
1053
|
+
asyncio.run(
|
|
1054
|
+
invoker.invoke(
|
|
1055
|
+
AgentInvocationRequest(
|
|
1056
|
+
agent_id=definition.agent_id,
|
|
1057
|
+
message="hello",
|
|
1058
|
+
context=_portable(),
|
|
1059
|
+
scope=InvocationScope(
|
|
1060
|
+
document_uids=["doc-a", "doc-b"],
|
|
1061
|
+
library_ids=["lib-1"],
|
|
1062
|
+
search_policy="strict",
|
|
1063
|
+
),
|
|
1064
|
+
)
|
|
1065
|
+
)
|
|
1066
|
+
)
|
|
1067
|
+
context = seen["context"]
|
|
1068
|
+
assert isinstance(context, dict)
|
|
1069
|
+
assert context["selected_document_uids"] == ["doc-a", "doc-b"]
|
|
1070
|
+
assert context["selected_document_libraries_ids"] == ["lib-1"]
|
|
1071
|
+
assert context["search_policy"] == "strict"
|
|
1072
|
+
|
|
1073
|
+
# Without scope → no narrowing keys are injected (no regression).
|
|
1074
|
+
seen.clear()
|
|
1075
|
+
asyncio.run(
|
|
1076
|
+
invoker.invoke(
|
|
1077
|
+
AgentInvocationRequest(
|
|
1078
|
+
agent_id=definition.agent_id,
|
|
1079
|
+
message="hello",
|
|
1080
|
+
context=_portable(),
|
|
1081
|
+
)
|
|
1082
|
+
)
|
|
1083
|
+
)
|
|
1084
|
+
context = seen["context"]
|
|
1085
|
+
assert isinstance(context, dict)
|
|
1086
|
+
assert "selected_document_uids" not in context
|
|
1087
|
+
assert "search_policy" not in context
|
|
1088
|
+
|
|
1089
|
+
|
|
997
1090
|
def test_resume_rejects_non_pending_checkpoint(monkeypatch, tmp_path) -> None:
|
|
998
1091
|
"""
|
|
999
1092
|
Ensure resume requests fail fast when the checkpoint is not waiting for input.
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# Copyright Thales 2026
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""Tests for typed, scoped agent invocation (RFC AGENT-INVOKE).
|
|
16
|
+
|
|
17
|
+
Covers the JSON-coercion helpers and ``GraphNodeContext.invoke_agent``'s structured
|
|
18
|
+
output + per-call scope + bounded-retry behaviour against a fake invoker.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import asyncio
|
|
22
|
+
|
|
23
|
+
from fred_sdk.contracts.context import (
|
|
24
|
+
AgentInvocationRequest,
|
|
25
|
+
AgentInvocationResult,
|
|
26
|
+
BoundRuntimeContext,
|
|
27
|
+
InvocationScope,
|
|
28
|
+
PortableContext,
|
|
29
|
+
PortableEnvironment,
|
|
30
|
+
RuntimeContext,
|
|
31
|
+
)
|
|
32
|
+
from fred_sdk.contracts.runtime import AgentInvokerPort, RuntimeServices
|
|
33
|
+
from pydantic import BaseModel
|
|
34
|
+
|
|
35
|
+
from fred_runtime.graph.graph_runtime import (
|
|
36
|
+
_GraphNodeExecutionContext,
|
|
37
|
+
_coerce_structured_payload,
|
|
38
|
+
_extract_json_object,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class _Extraction(BaseModel):
|
|
43
|
+
component: str
|
|
44
|
+
version: str | None = None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class _FakeInvoker(AgentInvokerPort):
|
|
48
|
+
"""Records requests and replays a fixed list of response contents."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, contents: list[str]) -> None:
|
|
51
|
+
self._contents = contents
|
|
52
|
+
self.requests: list[AgentInvocationRequest] = []
|
|
53
|
+
|
|
54
|
+
async def invoke(self, request: AgentInvocationRequest) -> AgentInvocationResult:
|
|
55
|
+
index = min(len(self.requests), len(self._contents) - 1)
|
|
56
|
+
self.requests.append(request)
|
|
57
|
+
return AgentInvocationResult(
|
|
58
|
+
agent_id=request.agent_id, content=self._contents[index]
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _context(invoker: AgentInvokerPort) -> _GraphNodeExecutionContext:
|
|
63
|
+
binding = BoundRuntimeContext(
|
|
64
|
+
runtime_context=RuntimeContext(session_id="s", user_id="u", team_id="t"),
|
|
65
|
+
portable_context=PortableContext(
|
|
66
|
+
request_id="r",
|
|
67
|
+
correlation_id="c",
|
|
68
|
+
actor="u",
|
|
69
|
+
tenant="t",
|
|
70
|
+
environment=PortableEnvironment.DEV,
|
|
71
|
+
session_id="s",
|
|
72
|
+
user_id="u",
|
|
73
|
+
team_id="t",
|
|
74
|
+
),
|
|
75
|
+
)
|
|
76
|
+
return _GraphNodeExecutionContext(
|
|
77
|
+
binding=binding,
|
|
78
|
+
services=RuntimeServices(agent_invoker=invoker),
|
|
79
|
+
model=None,
|
|
80
|
+
model_resolver=None,
|
|
81
|
+
graph_agent_id="caller",
|
|
82
|
+
node_id="node-1",
|
|
83
|
+
allowed_tool_refs=frozenset(),
|
|
84
|
+
runtime_tools={},
|
|
85
|
+
tuning_values={},
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# --- pure helpers ---------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_extract_json_object_handles_plain_fenced_and_embedded() -> None:
|
|
93
|
+
assert _extract_json_object('{"a": 1}') == {"a": 1}
|
|
94
|
+
assert _extract_json_object('```json\n{"a": 2}\n```') == {"a": 2}
|
|
95
|
+
assert _extract_json_object('text {"a": 3} more') == {"a": 3}
|
|
96
|
+
assert _extract_json_object("no json") is None
|
|
97
|
+
# a bare JSON array is not an object
|
|
98
|
+
assert _extract_json_object("[1, 2, 3]") is None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_coerce_structured_payload_validates_against_schema() -> None:
|
|
102
|
+
assert _coerce_structured_payload('{"component": "nginx"}', _Extraction) == {
|
|
103
|
+
"component": "nginx",
|
|
104
|
+
"version": None,
|
|
105
|
+
}
|
|
106
|
+
# missing required field → no coercion
|
|
107
|
+
assert _coerce_structured_payload('{"version": "1"}', _Extraction) is None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# --- invoke_agent: structured output + scope + retry ----------------------
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_invoke_agent_returns_validated_structured_payload() -> None:
|
|
114
|
+
invoker = _FakeInvoker(['{"component": "nginx", "version": "1.25"}'])
|
|
115
|
+
ctx = _context(invoker)
|
|
116
|
+
|
|
117
|
+
result = asyncio.run(
|
|
118
|
+
ctx.invoke_agent("callee", "is nginx installed?", output_schema=_Extraction)
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
assert result.structured == {"component": "nginx", "version": "1.25"}
|
|
122
|
+
assert len(invoker.requests) == 1
|
|
123
|
+
# the callee was asked for schema-conformant JSON, and the schema travelled too
|
|
124
|
+
assert "JSON Schema" in invoker.requests[0].message
|
|
125
|
+
assert invoker.requests[0].output_schema is not None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_invoke_agent_retries_then_gives_up_with_none() -> None:
|
|
129
|
+
invoker = _FakeInvoker(["not json", "still not json"])
|
|
130
|
+
ctx = _context(invoker)
|
|
131
|
+
|
|
132
|
+
result = asyncio.run(
|
|
133
|
+
ctx.invoke_agent("callee", "extract", output_schema=_Extraction)
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
assert result.structured is None
|
|
137
|
+
assert len(invoker.requests) == 2 # bounded retry happened
|
|
138
|
+
assert result.is_error is False # text answer still returned
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_invoke_agent_forwards_scope_and_stays_backward_compatible() -> None:
|
|
142
|
+
invoker = _FakeInvoker(["plain text answer"])
|
|
143
|
+
ctx = _context(invoker)
|
|
144
|
+
|
|
145
|
+
scope = InvocationScope(document_uids=["doc-1"], search_policy="strict")
|
|
146
|
+
result = asyncio.run(ctx.invoke_agent("callee", "hi", scope=scope))
|
|
147
|
+
|
|
148
|
+
assert result.content == "plain text answer"
|
|
149
|
+
assert result.structured is None # no schema requested
|
|
150
|
+
assert len(invoker.requests) == 1
|
|
151
|
+
assert invoker.requests[0].scope == scope
|
|
152
|
+
assert invoker.requests[0].output_schema is None
|
|
153
|
+
# message is untouched when no schema is requested
|
|
154
|
+
assert invoker.requests[0].message == "hi"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/integrations/v2_runtime/__init__.py
RENAMED
|
File without changes
|
{fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/integrations/v2_runtime/adapters.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/runtime_support/request_context_helpers.py
RENAMED
|
File without changes
|
{fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/runtime_support/sql_checkpointer.py
RENAMED
|
File without changes
|
{fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/runtime_support/user_token_refresher.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|