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.
Files changed (108) hide show
  1. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/PKG-INFO +1 -1
  2. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/app/agent_app.py +15 -0
  3. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/graph/graph_runtime.py +141 -9
  4. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime.egg-info/PKG-INFO +1 -1
  5. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime.egg-info/SOURCES.txt +1 -0
  6. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/pyproject.toml +1 -1
  7. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/tests/test_agent_app.py +93 -0
  8. fred_runtime-2.0.11/tests/test_graph_runtime_invoke_agent.py +154 -0
  9. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/README.md +0 -0
  10. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/__init__.py +0 -0
  11. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/app/__init__.py +0 -0
  12. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/app/_catalogs.py +0 -0
  13. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/app/config.py +0 -0
  14. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/app/config_loader.py +0 -0
  15. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/app/container.py +0 -0
  16. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/app/context.py +0 -0
  17. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/app/dependencies.py +0 -0
  18. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/app/mcp_config.py +0 -0
  19. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/app/observability_factory.py +0 -0
  20. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/app/openai_compat_router.py +0 -0
  21. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/cli/__init__.py +0 -0
  22. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/cli/completion.py +0 -0
  23. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/cli/entrypoint.py +0 -0
  24. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/cli/history_display.py +0 -0
  25. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/cli/kpi_display.py +0 -0
  26. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/cli/pod_client.py +0 -0
  27. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/cli/repl.py +0 -0
  28. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/cli/repl_helpers.py +0 -0
  29. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/cli/url_helpers.py +0 -0
  30. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/client.py +0 -0
  31. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/common/__init__.py +0 -0
  32. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/common/context_aware_tool.py +0 -0
  33. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/common/kf_base_client.py +0 -0
  34. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/common/kf_fast_text_client.py +0 -0
  35. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/common/kf_http_client.py +0 -0
  36. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/common/kf_logs_client.py +0 -0
  37. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/common/kf_markdown_media_client.py +0 -0
  38. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/common/kf_vectorsearch_client.py +0 -0
  39. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/common/kf_workspace_client.py +0 -0
  40. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/common/mcp_interceptors.py +0 -0
  41. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/common/mcp_runtime.py +0 -0
  42. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/common/mcp_toolkit.py +0 -0
  43. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/common/mcp_utils.py +0 -0
  44. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/common/structures.py +0 -0
  45. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/common/token_expiry.py +0 -0
  46. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/common/tool_node_utils.py +0 -0
  47. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/deep/__init__.py +0 -0
  48. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/deep/deep_runtime.py +0 -0
  49. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/eval/__init__.py +0 -0
  50. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/eval/collector.py +0 -0
  51. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/graph/__init__.py +0 -0
  52. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/integrations/__init__.py +0 -0
  53. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/integrations/v2_runtime/__init__.py +0 -0
  54. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/integrations/v2_runtime/adapters.py +0 -0
  55. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/model_routing/__init__.py +0 -0
  56. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/model_routing/catalog.py +0 -0
  57. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/model_routing/contracts.py +0 -0
  58. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/model_routing/provider.py +0 -0
  59. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/model_routing/resolver.py +0 -0
  60. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/react/__init__.py +0 -0
  61. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/react/react_langchain_adapter.py +0 -0
  62. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/react/react_message_codec.py +0 -0
  63. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/react/react_model_adapter.py +0 -0
  64. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/react/react_prompting.py +0 -0
  65. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/react/react_runtime.py +0 -0
  66. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/react/react_stream_adapter.py +0 -0
  67. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/react/react_tool_binding.py +0 -0
  68. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/react/react_tool_loop.py +0 -0
  69. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/react/react_tool_rendering.py +0 -0
  70. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/react/react_tool_resolution.py +0 -0
  71. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/react/react_tool_utils.py +0 -0
  72. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/react/react_tracing.py +0 -0
  73. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/runtime_context.py +0 -0
  74. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/runtime_support/__init__.py +0 -0
  75. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/runtime_support/checkpoints.py +0 -0
  76. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/runtime_support/model_metadata.py +0 -0
  77. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/runtime_support/request_context_helpers.py +0 -0
  78. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/runtime_support/sql_checkpointer.py +0 -0
  79. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/runtime_support/user_token_refresher.py +0 -0
  80. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/support/__init__.py +0 -0
  81. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/support/filesystem_context.py +0 -0
  82. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/support/tool_approval.py +0 -0
  83. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime/support/tool_loop.py +0 -0
  84. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime.egg-info/dependency_links.txt +0 -0
  85. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime.egg-info/entry_points.txt +0 -0
  86. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime.egg-info/requires.txt +0 -0
  87. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/fred_runtime.egg-info/top_level.txt +0 -0
  88. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/setup.cfg +0 -0
  89. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/tests/test_client.py +0 -0
  90. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/tests/test_config_loader.py +0 -0
  91. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/tests/test_context.py +0 -0
  92. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/tests/test_context_aware_tool.py +0 -0
  93. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/tests/test_conversational_memory.py +0 -0
  94. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/tests/test_eval_collector.py +0 -0
  95. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/tests/test_eval_trace.py +0 -0
  96. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/tests/test_graph_runtime_observability.py +0 -0
  97. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/tests/test_history.py +0 -0
  98. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/tests/test_kf_workspace_client.py +0 -0
  99. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/tests/test_kpi_display.py +0 -0
  100. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/tests/test_mcp_config.py +0 -0
  101. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/tests/test_model_routing.py +0 -0
  102. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/tests/test_openai_compat_router.py +0 -0
  103. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/tests/test_pod_client.py +0 -0
  104. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/tests/test_repl_helpers.py +0 -0
  105. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/tests/test_smoke.py +0 -0
  106. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/tests/test_token_expiry.py +0 -0
  107. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/tests/test_url_helpers.py +0 -0
  108. {fred_runtime-2.0.10 → fred_runtime-2.0.11}/tests/test_user_token_refresher.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fred-runtime
3
- Version: 2.0.10
3
+ Version: 2.0.11
4
4
  Summary: Runtime adapters and infrastructure wiring for Fred v2 agents.
5
5
  Author-email: Thales <noreply@thalesgroup.com>
6
6
  License: Apache-2.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
- result = await agent_invoker.invoke(
720
- AgentInvocationRequest(
721
- agent_id=agent_id,
722
- message=message,
723
- context=self.binding.portable_context,
724
- prior_turns=prior_turns,
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(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fred-runtime
3
- Version: 2.0.10
3
+ Version: 2.0.11
4
4
  Summary: Runtime adapters and infrastructure wiring for Fred v2 agents.
5
5
  Author-email: Thales <noreply@thalesgroup.com>
6
6
  License: Apache-2.0
@@ -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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fred-runtime"
3
- version = "2.0.10"
3
+ version = "2.0.11"
4
4
  description = "Runtime adapters and infrastructure wiring for Fred v2 agents."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12,<3.13"
@@ -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