licos-agent-runtime 0.3.0__tar.gz → 0.3.2__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 (82) hide show
  1. {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/PKG-INFO +2 -2
  2. {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/pyproject.toml +2 -2
  3. {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/agent_config.py +14 -0
  4. licos_agent_runtime-0.3.2/src/licos_agent_runtime/agent_graph.py +249 -0
  5. {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/app.py +106 -2
  6. licos_agent_runtime-0.3.2/src/licos_agent_runtime/harness_approval.py +138 -0
  7. licos_agent_runtime-0.3.2/src/licos_agent_runtime/harness_config.py +110 -0
  8. licos_agent_runtime-0.3.2/src/licos_agent_runtime/harness_execution.py +128 -0
  9. licos_agent_runtime-0.3.2/src/licos_agent_runtime/harness_planning.py +67 -0
  10. licos_agent_runtime-0.3.2/src/licos_agent_runtime/harness_verification.py +64 -0
  11. licos_agent_runtime-0.3.2/src/licos_agent_runtime/openai.py +113 -0
  12. licos_agent_runtime-0.3.2/src/licos_agent_runtime/project_agent.py +77 -0
  13. {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/project_files.py +4 -3
  14. licos_agent_runtime-0.3.2/src/licos_agent_runtime/project_history.py +69 -0
  15. {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/run_config.py +4 -0
  16. {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/service.py +155 -23
  17. licos_agent_runtime-0.3.2/src/licos_agent_runtime/session_memory.py +169 -0
  18. {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/stream_protocol.py +53 -0
  19. {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/stream_runner.py +1 -1
  20. {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/trace_records.py +11 -0
  21. {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/verify.py +24 -5
  22. {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/tests/test_execution_errors.py +70 -0
  23. licos_agent_runtime-0.3.2/tests/test_harness.py +839 -0
  24. licos_agent_runtime-0.3.2/tests/test_openai.py +92 -0
  25. {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/tests/test_stream_protocol.py +33 -0
  26. {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/tests/test_verify.py +16 -1
  27. licos_agent_runtime-0.3.0/src/licos_agent_runtime/agent_graph.py +0 -115
  28. licos_agent_runtime-0.3.0/src/licos_agent_runtime/openai.py +0 -98
  29. licos_agent_runtime-0.3.0/src/licos_agent_runtime/project_agent.py +0 -55
  30. {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/.gitignore +0 -0
  31. {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/README.md +0 -0
  32. {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/__init__.py +0 -0
  33. {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/__main__.py +0 -0
  34. {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/agent_canvas.py +0 -0
  35. {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/agent_input.py +0 -0
  36. {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/attachments.py +0 -0
  37. {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/context.py +0 -0
  38. {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/credit_availability.py +0 -0
  39. {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/errors.py +0 -0
  40. {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/event_store.py +0 -0
  41. {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/event_subscriptions.py +0 -0
  42. {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/graph_contract.py +0 -0
  43. {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/graph_loader.py +0 -0
  44. {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/logging.py +0 -0
  45. {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/model_identity.py +0 -0
  46. {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/model_usage.py +0 -0
  47. {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/observability.py +0 -0
  48. {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/output_files.py +0 -0
  49. {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/parser.py +0 -0
  50. {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/payload.py +0 -0
  51. {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/project_skills.py +0 -0
  52. {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/runtime_config.py +0 -0
  53. {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/session_files.py +0 -0
  54. {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/telemetry.py +0 -0
  55. {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/workflow_canvas.py +0 -0
  56. {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/workflow_protocol.py +0 -0
  57. {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/tests/test_agent_config.py +0 -0
  58. {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/tests/test_agent_graph.py +0 -0
  59. {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/tests/test_app_cors.py +0 -0
  60. {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/tests/test_attachment_inputs.py +0 -0
  61. {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/tests/test_context.py +0 -0
  62. {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/tests/test_credit_availability.py +0 -0
  63. {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/tests/test_credit_availability_routes.py +0 -0
  64. {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/tests/test_deployment_layout.py +0 -0
  65. {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/tests/test_event_delivery_api.py +0 -0
  66. {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/tests/test_event_subscriptions.py +0 -0
  67. {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/tests/test_graph_contract.py +0 -0
  68. {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/tests/test_graph_loader.py +0 -0
  69. {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/tests/test_model_identity.py +0 -0
  70. {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/tests/test_model_usage.py +0 -0
  71. {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/tests/test_observability.py +0 -0
  72. {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/tests/test_parser.py +0 -0
  73. {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/tests/test_payload.py +0 -0
  74. {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/tests/test_project_files.py +0 -0
  75. {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/tests/test_project_skills.py +0 -0
  76. {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/tests/test_run_config.py +0 -0
  77. {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/tests/test_runtime_config.py +0 -0
  78. {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/tests/test_service_stream_protocol.py +0 -0
  79. {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/tests/test_static_assets.py +0 -0
  80. {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/tests/test_stream_runner.py +0 -0
  81. {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/tests/test_trace_records.py +0 -0
  82. {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/tests/test_workflow_protocol.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: licos-agent-runtime
3
- Version: 0.3.0
3
+ Version: 0.3.2
4
4
  Summary: LICOS Python agent runtime for LangGraph/LangChain projects
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: fastapi<1,>=0.121
@@ -14,7 +14,7 @@ Requires-Dist: langgraph-prebuilt<2,>=1.0
14
14
  Requires-Dist: langgraph-sdk<1,>=0.3
15
15
  Requires-Dist: langgraph<2,>=1.1
16
16
  Requires-Dist: langsmith<1,>=0.4
17
- Requires-Dist: licos-dev-sdk>=0.4.8
17
+ Requires-Dist: licos-dev-sdk>=0.4.9
18
18
  Requires-Dist: licos-platform-sdk>=0.5.7
19
19
  Requires-Dist: pydantic<3,>=2.12
20
20
  Requires-Dist: pyyaml<7,>=6
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "licos-agent-runtime"
7
- version = "0.3.0"
7
+ version = "0.3.2"
8
8
  description = "LICOS Python agent runtime for LangGraph/LangChain projects"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -22,7 +22,7 @@ dependencies = [
22
22
  "langsmith>=0.4,<1",
23
23
  "httpx>=0.27,<1",
24
24
  "licos-platform-sdk>=0.5.7",
25
- "licos-dev-sdk>=0.4.8",
25
+ "licos-dev-sdk>=0.4.9",
26
26
  "filelock>=3.16,<4",
27
27
  "PyYAML>=6,<7",
28
28
  "wcmatch>=10,<11",
@@ -5,6 +5,7 @@ from pathlib import Path
5
5
  from typing import Any
6
6
 
7
7
  from .graph_loader import project_root
8
+ from .harness_config import load_agent_project_config
8
9
 
9
10
  AGENT_CONFIG_RELATIVE_PATH = Path("config") / "agent_llm_config.json"
10
11
  AGENT_CONFIG_PATH = AGENT_CONFIG_RELATIVE_PATH.as_posix()
@@ -19,6 +20,9 @@ def _empty_payload(*, exists: bool = False) -> dict[str, Any]:
19
20
  "up": "",
20
21
  "tools": [],
21
22
  "raw": {},
23
+ "harness": None,
24
+ "harness_version": 0,
25
+ "harness_digest": None,
22
26
  }
23
27
 
24
28
 
@@ -67,4 +71,14 @@ def read_agent_config(root: Path | None = None) -> dict[str, Any]:
67
71
  payload["sp"] = _text(raw.get("sp"))
68
72
  payload["up"] = _text(raw.get("up"))
69
73
  payload["tools"] = _tools(raw.get("tools"))
74
+ try:
75
+ snapshot = load_agent_project_config(base)
76
+ except (ValueError, TypeError) as exc:
77
+ payload["error"] = str(exc)
78
+ return payload
79
+ if snapshot is not None:
80
+ payload["harness_digest"] = snapshot.digest
81
+ if snapshot.harness is not None:
82
+ payload["harness"] = snapshot.harness.model_dump()
83
+ payload["harness_version"] = snapshot.harness.version
70
84
  return payload
@@ -0,0 +1,249 @@
1
+ """Business-neutral model/tool loop; applications own model and tool creation."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import time
6
+ from typing import Annotated, Any, Sequence
7
+ from typing_extensions import TypedDict
8
+
9
+ from langchain_core.messages import AnyMessage, HumanMessage, SystemMessage, ToolMessage
10
+ from langchain_core.runnables import RunnableConfig, RunnableLambda
11
+ from langchain_core.tools import BaseTool
12
+ from langgraph.graph import END, START, StateGraph
13
+ from langgraph.graph.message import add_messages
14
+ from langgraph.prebuilt import ToolNode, tools_condition
15
+
16
+ from .harness_config import HarnessSettings
17
+ from .harness_execution import HarnessLimitError, HarnessRun
18
+ from .harness_planning import parse_plan, planning_messages
19
+ from .harness_approval import ApprovalRegistry
20
+ from .context import Context
21
+ from .harness_verification import HarnessVerificationError, ProjectVerifier, verify_tool_evidence
22
+
23
+
24
+ class AgentState(TypedDict, total=False):
25
+ messages: Annotated[list[AnyMessage], add_messages]
26
+ input: str | list[dict[str, Any]] | None
27
+ event: dict[str, Any] | None
28
+ attachments: list[dict[str, Any]]
29
+ files: list[dict[str, Any]]
30
+ references: list[dict[str, Any]]
31
+ repair_attempts: int
32
+ verification_issue: str | None
33
+ plan: list[str]
34
+ plan_reason: str
35
+
36
+
37
+ def validate_tool_registry(tools: Sequence[BaseTool], configured_names: list[str]) -> None:
38
+ names = [tool.name for tool in tools]
39
+ if len(set(names)) != len(names) or len(set(configured_names)) != len(configured_names):
40
+ raise ValueError("Agent tool names must be unique")
41
+ if set(names) != set(configured_names):
42
+ raise ValueError(
43
+ f"Agent tool registry does not match config: registered={names}, configured={configured_names}"
44
+ )
45
+
46
+
47
+ def _prepare_input(state: AgentState) -> dict[str, Any]:
48
+ existing = state.get("messages", [])
49
+ added: list[AnyMessage] = []
50
+ content = state.get("input")
51
+ if content is not None:
52
+ if not isinstance(content, (str, list)):
53
+ raise ValueError("input must be text or message content blocks; use event for structured events")
54
+ if content and not (
55
+ existing and isinstance(existing[-1], HumanMessage) and existing[-1].content == content
56
+ ):
57
+ added.append(HumanMessage(content=content))
58
+ event = state.get("event")
59
+ if event is not None:
60
+ if not isinstance(event, dict) or not event:
61
+ raise ValueError("event must be a non-empty JSON object")
62
+ added.append(HumanMessage(content=json.dumps({"event": event}, ensure_ascii=False)))
63
+ if not existing and not added:
64
+ raise ValueError("Agent request requires non-empty input, messages or event")
65
+ # Consume request fields so a checkpoint cannot replay them on the next turn.
66
+ return {"messages": added, "input": None, "event": None}
67
+
68
+
69
+ def recent_turns(messages: list[AnyMessage], max_messages: int) -> list[AnyMessage]:
70
+ """Drop whole old turns, never split the current model/tool exchange.
71
+
72
+ The most recent turn may exceed this soft limit. LangGraph's recursion
73
+ limit bounds its execution; truncating tool pairs corrupts it.
74
+ """
75
+ # Adjacent user messages belong to the same turn until the model responds.
76
+ starts = [index for index, item in enumerate(messages)
77
+ if isinstance(item, HumanMessage)
78
+ and (index == 0 or not isinstance(messages[index - 1], HumanMessage))]
79
+ if not starts:
80
+ raise ValueError("Agent conversation requires a user message")
81
+ start = starts[0]
82
+ for next_start in starts[1:]:
83
+ if len(messages) - start <= max_messages:
84
+ break
85
+ start = next_start
86
+ return messages[start:]
87
+
88
+
89
+ def build_agent_graph(
90
+ model: Any,
91
+ tools: Sequence[BaseTool] = (),
92
+ *,
93
+ system_prompt: str,
94
+ max_messages: int = 40,
95
+ checkpointer: Any = None,
96
+ config_path: str = "config/agent_llm_config.json",
97
+ harness: HarnessSettings | None = None,
98
+ harness_run: HarnessRun | None = None,
99
+ verifier: ProjectVerifier | None = None,
100
+ approvals: ApprovalRegistry | None = None,
101
+ ctx: Context | None = None,
102
+ ) -> Any:
103
+ if max_messages < 1:
104
+ raise ValueError("max_messages must be positive")
105
+ registered = list(tools)
106
+ validate_tool_registry(registered, [tool.name for tool in registered])
107
+ if harness is not None:
108
+ unknown = set(harness.tool_policy.require_approval) - {tool.name for tool in registered}
109
+ if unknown:
110
+ raise ValueError(f"Approval tools are not registered: {sorted(unknown)}")
111
+ if harness.tool_policy.require_approval and (approvals is None or ctx is None or harness_run is None):
112
+ raise ValueError("Tool approvals require a runtime request context and approval registry")
113
+ if harness.verification.mode == "project" and verifier is None:
114
+ raise ValueError("verification.mode=project requires a registered project verifier")
115
+ if harness_run is not None:
116
+ harness_run.tool_schema_tokens = sum(
117
+ len(json.dumps({"name": tool.name, "description": tool.description, "args": tool.args},
118
+ ensure_ascii=False, default=str).encode("utf-8")) + 12
119
+ for tool in registered
120
+ )
121
+ bound_model = model.bind_tools(registered) if registered else model
122
+
123
+ def model_input(state: AgentState) -> list[AnyMessage]:
124
+ system = [SystemMessage(content=system_prompt)]
125
+ if state.get("plan"):
126
+ steps = "\n".join(f"{index}. {step}" for index, step in enumerate(state["plan"], start=1))
127
+ system.append(SystemMessage(content=f"Current task plan:\n{steps}\nCheck each step against tool results before claiming completion."))
128
+ conversation = state["messages"] if harness is not None else recent_turns(state["messages"], max_messages)
129
+ messages = [*system, *conversation]
130
+ return harness_run.fit_context(messages) if harness_run is not None else messages
131
+
132
+ def respond(state: AgentState, config: RunnableConfig) -> dict[str, Any]:
133
+ if harness_run is not None:
134
+ harness_run.before_model()
135
+ return {"messages": [bound_model.invoke(model_input(state), config=config)]}
136
+
137
+ async def arespond(state: AgentState, config: RunnableConfig) -> dict[str, Any]:
138
+ if harness_run is not None:
139
+ harness_run.before_model()
140
+ return {"messages": [await bound_model.ainvoke(model_input(state), config=config)]}
141
+
142
+ builder = StateGraph(AgentState)
143
+ builder.add_node("normalize", _prepare_input)
144
+ builder.add_node(
145
+ "agent", RunnableLambda(respond, afunc=arespond),
146
+ metadata={"dependencies": {"llm_config": [config_path]}},
147
+ )
148
+ builder.add_edge(START, "normalize")
149
+ if harness is not None and harness.planning.mode != "off":
150
+ planning_model = model.bind(response_format={"type": "json_object"})
151
+ def plan(state: AgentState, config: RunnableConfig) -> dict[str, Any]:
152
+ if harness_run is not None:
153
+ harness_run.before_model()
154
+ prompt = planning_messages(state["messages"], harness.planning)
155
+ if harness_run is not None:
156
+ prompt = harness_run.fit_context(prompt)
157
+ decision = parse_plan(planning_model.invoke(prompt, config=config), harness.planning, harness_run)
158
+ return {"plan": decision.steps, "plan_reason": decision.reason}
159
+
160
+ async def aplan(state: AgentState, config: RunnableConfig) -> dict[str, Any]:
161
+ if harness_run is not None:
162
+ harness_run.before_model()
163
+ prompt = planning_messages(state["messages"], harness.planning)
164
+ if harness_run is not None:
165
+ prompt = harness_run.fit_context(prompt)
166
+ decision = parse_plan(await planning_model.ainvoke(prompt, config=config), harness.planning, harness_run)
167
+ return {"plan": decision.steps, "plan_reason": decision.reason}
168
+
169
+ builder.add_node("plan", RunnableLambda(plan, afunc=aplan))
170
+ builder.add_edge("normalize", "plan")
171
+ builder.add_edge("plan", "agent")
172
+ else:
173
+ builder.add_edge("normalize", "agent")
174
+ if registered:
175
+ if harness_run is not None:
176
+ def check_tool(request: Any, execute: Any) -> Any:
177
+ if harness is not None and request.tool_call["name"] in harness.tool_policy.require_approval:
178
+ raise RuntimeError("Approval tools require asynchronous execution")
179
+ harness_run.before_tool(request.tool_call["name"])
180
+ result = execute(request)
181
+ harness_run.record_tool_result(
182
+ request.tool_call["name"], request.tool_call["id"],
183
+ result.status if isinstance(result, ToolMessage) else "completed",
184
+ )
185
+ return result
186
+
187
+ async def acheck_tool(request: Any, execute: Any) -> Any:
188
+ if harness is not None and request.tool_call["name"] in harness.tool_policy.require_approval:
189
+ assert approvals is not None and ctx is not None
190
+ remaining = harness.execution.max_duration_seconds - (time.monotonic() - harness_run.started)
191
+ approved = await approvals.require(ctx, request.tool_call, max(0, remaining))
192
+ if not approved:
193
+ harness_run.record_tool_result(request.tool_call["name"], request.tool_call["id"], "denied")
194
+ return ToolMessage(
195
+ content="User denied this tool call; it was not executed.",
196
+ name=request.tool_call["name"], tool_call_id=request.tool_call["id"], status="error",
197
+ )
198
+ harness_run.before_tool(request.tool_call["name"])
199
+ result = await execute(request)
200
+ harness_run.record_tool_result(
201
+ request.tool_call["name"], request.tool_call["id"],
202
+ result.status if isinstance(result, ToolMessage) else "completed",
203
+ )
204
+ return result
205
+
206
+ def handle_tool_error(exc: Exception) -> str:
207
+ if isinstance(exc, HarnessLimitError):
208
+ raise exc
209
+ return str(exc)
210
+
211
+ node = ToolNode(
212
+ registered, handle_tool_errors=handle_tool_error,
213
+ wrap_tool_call=check_tool, awrap_tool_call=acheck_tool,
214
+ )
215
+ else:
216
+ node = ToolNode(registered, handle_tool_errors=True)
217
+ builder.add_node("tools", node)
218
+ builder.add_edge("tools", "agent")
219
+
220
+ if harness is not None and harness.verification.mode != "off":
221
+ def verify(state: AgentState) -> dict[str, Any]:
222
+ reason = (
223
+ verifier(state["messages"]) if harness.verification.mode == "project" and verifier is not None
224
+ else verify_tool_evidence(state["messages"])
225
+ )
226
+ if harness_run is not None:
227
+ harness_run.events.append({"type": "verification", "passed": reason is None, "reason": reason})
228
+ if reason is None:
229
+ return {"verification_issue": None}
230
+ attempts = state.get("repair_attempts", 0)
231
+ if attempts >= harness.verification.max_repair_attempts:
232
+ raise HarnessVerificationError(reason)
233
+ return {
234
+ "verification_issue": reason,
235
+ "repair_attempts": attempts + 1,
236
+ "messages": [HumanMessage(content=f"Verification failed: {reason}. Correct the result and answer again.")],
237
+ }
238
+
239
+ builder.add_node("verify", verify)
240
+ if registered:
241
+ builder.add_conditional_edges("agent", lambda state: "tools" if getattr(state["messages"][-1], "tool_calls", None) else "verify")
242
+ else:
243
+ builder.add_edge("agent", "verify")
244
+ builder.add_conditional_edges("verify", lambda state: "agent" if state.get("verification_issue") else END)
245
+ elif registered:
246
+ builder.add_conditional_edges("agent", tools_condition)
247
+ else:
248
+ builder.add_edge("agent", END)
249
+ return builder.compile(checkpointer=checkpointer)
@@ -23,6 +23,7 @@ from .event_subscriptions import EventSubscriptions
23
23
  from .graph_loader import is_agent_project
24
24
  from .openai import OpenAIChatHandler
25
25
  from .service import GraphService
26
+ from .session_files import session_user_id
26
27
  from .stream_runner import RunOpt, agent_stream_handler, workflow_stream_handler
27
28
  from .trace_records import clear_run_records, get_run_record, list_run_records
28
29
 
@@ -49,6 +50,7 @@ def create_app(service: GraphService | None = None) -> FastAPI:
49
50
  runtime_service = service or GraphService()
50
51
  openai_handler = OpenAIChatHandler(runtime_service)
51
52
  subscriptions = EventSubscriptions(runtime_service)
53
+ interactive_runs: dict[str, tuple[asyncio.Task, str, float]] = {}
52
54
 
53
55
  @asynccontextmanager
54
56
  async def lifespan(app: FastAPI):
@@ -77,6 +79,10 @@ def create_app(service: GraphService | None = None) -> FastAPI:
77
79
  return JSONResponse(status_code=exc.status_code, content=exc.response_body())
78
80
 
79
81
  def register_task(run_id: str, task: asyncio.Task) -> None:
82
+ active = runtime_service.running_tasks.get(run_id)
83
+ if active is not None and not active.done():
84
+ task.cancel()
85
+ raise ValueError(f"run_id is already active: {run_id}")
80
86
  runtime_service.running_tasks[run_id] = task
81
87
 
82
88
  @app.get("/agent/subscriptions")
@@ -142,7 +148,31 @@ def create_app(service: GraphService | None = None) -> FastAPI:
142
148
  owner_org_id=ctx.identity.owner_org_id,
143
149
  workspace_id=ctx.identity.workspace_id,
144
150
  )
145
- result = await runtime_service.run_with_timeout(payload, ctx)
151
+ if is_agent_project():
152
+ watch = runtime_service.approvals.watch(ctx.run_id)
153
+ execution = asyncio.create_task(runtime_service.run_with_timeout(payload, ctx))
154
+ watch_task = asyncio.create_task(watch.wait())
155
+ try:
156
+ await asyncio.wait({execution, watch_task}, return_when=asyncio.FIRST_COMPLETED)
157
+ if execution.done():
158
+ result = await execution
159
+ else:
160
+ user_id = session_user_id(ctx)
161
+ interactive_runs[ctx.run_id] = (execution, user_id, time.monotonic())
162
+ for old_run_id, (old_task, _, started) in list(interactive_runs.items()):
163
+ if old_task.done() and time.monotonic() - started > 3600:
164
+ del interactive_runs[old_run_id]
165
+ status_code = 202
166
+ return JSONResponse(status_code=202, content={
167
+ "status": "pending_approval",
168
+ "run_id": ctx.run_id,
169
+ "pending": runtime_service.approvals.list_for(ctx, ctx.run_id),
170
+ })
171
+ finally:
172
+ watch_task.cancel()
173
+ runtime_service.approvals.unwatch(ctx.run_id)
174
+ else:
175
+ result = await runtime_service.run_with_timeout(payload, ctx)
146
176
  if isinstance(result, dict) and result.get("status") == "timeout":
147
177
  status_code = 504
148
178
  error_code = "TIMEOUT"
@@ -263,7 +293,56 @@ def create_app(service: GraphService | None = None) -> FastAPI:
263
293
  async def http_cancel(run_id: str, request: Request) -> dict[str, Any]:
264
294
  ctx = new_context(method="cancel", headers=request.headers)
265
295
  request_context.set(ctx)
266
- return runtime_service.cancel_run(run_id, ctx)
296
+ try:
297
+ return runtime_service.cancel_run(run_id, ctx)
298
+ except ValueError as exc:
299
+ raise HTTPException(status_code=401, detail=str(exc)) from exc
300
+ except PermissionError as exc:
301
+ raise HTTPException(status_code=403, detail=str(exc)) from exc
302
+
303
+ @app.get("/agent/approvals/{run_id}")
304
+ async def agent_approvals(run_id: str, request: Request) -> dict[str, Any]:
305
+ ctx = new_context(method="approval_list", headers=request.headers)
306
+ try:
307
+ return {"pending": runtime_service.approvals.list_for(ctx, run_id)}
308
+ except ValueError as exc:
309
+ raise HTTPException(status_code=401, detail=str(exc)) from exc
310
+
311
+ @app.get("/agent/runs/{run_id}")
312
+ async def agent_run_result(run_id: str, request: Request) -> dict[str, Any]:
313
+ ctx = new_context(method="agent_run_result", headers=request.headers)
314
+ entry = interactive_runs.get(run_id)
315
+ if entry is None:
316
+ raise HTTPException(status_code=404, detail="Interactive run not found")
317
+ execution, owner_id, _ = entry
318
+ if session_user_id(ctx) != owner_id:
319
+ raise HTTPException(status_code=403, detail="Run belongs to another user")
320
+ if not execution.done():
321
+ return {"status": "running", "run_id": run_id}
322
+ try:
323
+ result = execution.result()
324
+ if isinstance(result, dict) and result.get("status") == "timeout":
325
+ return {"status": "timeout", "run_id": run_id, "message": result["message"]}
326
+ return {"status": "completed", "run_id": run_id, "result": result}
327
+ except asyncio.CancelledError:
328
+ return {"status": "cancelled", "run_id": run_id}
329
+ except Exception as exc:
330
+ return {"status": "error", "run_id": run_id, "error": str(exc)}
331
+
332
+ @app.post("/agent/approvals/{run_id}/{tool_call_id}")
333
+ async def decide_agent_approval(run_id: str, tool_call_id: str, request: Request) -> dict[str, Any]:
334
+ ctx = new_context(method="approval_decide", headers=request.headers)
335
+ body = await request.json()
336
+ if not isinstance(body, dict) or not isinstance(body.get("approved"), bool):
337
+ raise HTTPException(status_code=400, detail="approved must be a boolean")
338
+ try:
339
+ return runtime_service.approvals.decide(ctx, run_id, tool_call_id, body["approved"])
340
+ except KeyError as exc:
341
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
342
+ except PermissionError as exc:
343
+ raise HTTPException(status_code=403, detail=str(exc)) from exc
344
+ except ValueError as exc:
345
+ raise HTTPException(status_code=409, detail=str(exc)) from exc
267
346
 
268
347
  @app.post("/node_run/{node_id}")
269
348
  async def http_node_run(node_id: str, request: Request) -> Any:
@@ -390,6 +469,31 @@ def create_app(service: GraphService | None = None) -> FastAPI:
390
469
  del request
391
470
  return runtime_service.agent_config_info()
392
471
 
472
+ @app.get("/agent/history/{session_id}")
473
+ async def http_agent_history(
474
+ session_id: str,
475
+ request: Request,
476
+ limit: int = Query(20, ge=1, le=100),
477
+ offset: int = Query(0, ge=0),
478
+ ) -> dict[str, Any]:
479
+ ctx = new_context(method="agent_history", headers=request.headers)
480
+ try:
481
+ return await runtime_service.conversation_history(session_id, ctx, limit, offset)
482
+ except LookupError as exc:
483
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
484
+ except ValueError as exc:
485
+ raise HTTPException(status_code=401, detail=str(exc)) from exc
486
+
487
+ @app.delete("/agent/history/{session_id}")
488
+ async def http_agent_history_delete(session_id: str, request: Request) -> dict[str, Any]:
489
+ ctx = new_context(method="agent_history_delete", headers=request.headers)
490
+ try:
491
+ return await runtime_service.delete_conversation_history(session_id, ctx)
492
+ except LookupError as exc:
493
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
494
+ except ValueError as exc:
495
+ raise HTTPException(status_code=401, detail=str(exc)) from exc
496
+
393
497
  @app.get("/run_records")
394
498
  async def http_run_records(
395
499
  request: Request,
@@ -0,0 +1,138 @@
1
+ """One-use, authenticated, process-local tool decisions."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ from dataclasses import dataclass
6
+ import time
7
+ from typing import Any
8
+
9
+ from .context import Context
10
+ from .session_files import session_user_id
11
+
12
+
13
+ @dataclass(slots=True)
14
+ class PendingApproval:
15
+ run_id: str
16
+ session_id: str
17
+ tool_call_id: str
18
+ user_id: str
19
+ tool_name: str
20
+ created: float
21
+ future: asyncio.Future[bool]
22
+ event_queue: asyncio.Queue[Any] | None
23
+ harness_run: Any | None
24
+
25
+ def public(self) -> dict[str, Any]:
26
+ return {
27
+ "run_id": self.run_id,
28
+ "session_id": self.session_id,
29
+ "tool_call_id": self.tool_call_id,
30
+ "tool_name": self.tool_name,
31
+ "created_monotonic": self.created,
32
+ }
33
+
34
+
35
+ class ApprovalRegistry:
36
+ def __init__(self) -> None:
37
+ self._pending: dict[tuple[str, str], PendingApproval] = {}
38
+ self._decided: dict[tuple[str, str], bool] = {}
39
+ self._watches: dict[str, asyncio.Event] = {}
40
+
41
+ def watch(self, run_id: str) -> asyncio.Event:
42
+ event = asyncio.Event()
43
+ self._watches[run_id] = event
44
+ return event
45
+
46
+ def unwatch(self, run_id: str) -> None:
47
+ self._watches.pop(run_id, None)
48
+
49
+ async def require(self, ctx: Context, tool_call: dict[str, Any], timeout: float) -> bool:
50
+ user_id = session_user_id(ctx)
51
+ tool_call_id = str(tool_call["id"])
52
+ key = (ctx.run_id, tool_call_id)
53
+ if key in self._pending or key in self._decided:
54
+ raise ValueError(f"Tool call was already submitted for approval: {tool_call_id}")
55
+ queue = ctx.metadata.get("agent_event_queue")
56
+ pending = PendingApproval(
57
+ run_id=ctx.run_id,
58
+ session_id=str(ctx.metadata.get("session_id") or ctx.run_id),
59
+ tool_call_id=tool_call_id,
60
+ user_id=user_id,
61
+ tool_name=str(tool_call["name"]),
62
+ created=time.monotonic(),
63
+ future=asyncio.get_running_loop().create_future(),
64
+ event_queue=queue,
65
+ harness_run=ctx.metadata.get("agent_harness_run"),
66
+ )
67
+ if pending.harness_run is not None:
68
+ pending.harness_run.events.append({"type": "approval_required", "tool_call_id": tool_call_id,
69
+ "tool_name": pending.tool_name})
70
+ self._pending[key] = pending
71
+ watch = self._watches.get(ctx.run_id)
72
+ if watch is not None:
73
+ watch.set()
74
+ if queue is not None:
75
+ event = {
76
+ "type": "approval_required",
77
+ "run_id": pending.run_id,
78
+ "session_id": pending.session_id,
79
+ "tool_call_id": tool_call_id,
80
+ "tool_name": pending.tool_name,
81
+ "input": tool_call.get("args", {}),
82
+ }
83
+ queue.put_nowait({**event, "content": event})
84
+ try:
85
+ return await asyncio.wait_for(pending.future, timeout=timeout)
86
+ except asyncio.TimeoutError:
87
+ if pending.harness_run is not None:
88
+ pending.harness_run.events.append({"type": "approval_timeout", "tool_call_id": tool_call_id})
89
+ raise
90
+ except asyncio.CancelledError:
91
+ if pending.harness_run is not None:
92
+ pending.harness_run.events.append({"type": "approval_cancelled", "tool_call_id": tool_call_id})
93
+ raise
94
+ finally:
95
+ self._pending.pop(key, None)
96
+ self._decided.setdefault(key, False)
97
+
98
+ def decide(self, ctx: Context, run_id: str, tool_call_id: str, approved: bool) -> dict[str, Any]:
99
+ pending = self._pending.get((run_id, tool_call_id))
100
+ if pending is None:
101
+ if (run_id, tool_call_id) in self._decided:
102
+ raise ValueError("Approval was already decided")
103
+ raise KeyError("Approval request is absent or already decided")
104
+ if pending.user_id != session_user_id(ctx):
105
+ raise PermissionError("Only the requesting user may decide this tool call")
106
+ if pending.future.done():
107
+ raise ValueError("Approval was already decided")
108
+ pending.future.set_result(approved)
109
+ self._decided[(run_id, tool_call_id)] = approved
110
+ if pending.harness_run is not None:
111
+ pending.harness_run.events.append({"type": "approval_decision", "tool_call_id": tool_call_id,
112
+ "approved": approved})
113
+ if pending.event_queue is not None:
114
+ event = {
115
+ "type": "approval_response",
116
+ "run_id": run_id,
117
+ "session_id": pending.session_id,
118
+ "tool_call_id": tool_call_id,
119
+ "approved": approved,
120
+ }
121
+ pending.event_queue.put_nowait({**event, "content": event})
122
+ return {"run_id": run_id, "tool_call_id": tool_call_id, "approved": approved}
123
+
124
+ def list_for(self, ctx: Context, run_id: str) -> list[dict[str, Any]]:
125
+ user_id = session_user_id(ctx)
126
+ return [
127
+ pending.public() for (current_run_id, _), pending in self._pending.items()
128
+ if current_run_id == run_id and pending.user_id == user_id and not pending.future.done()
129
+ ]
130
+
131
+ def forget_run(self, run_id: str) -> None:
132
+ for key, pending in list(self._pending.items()):
133
+ if key[0] == run_id:
134
+ if not pending.future.done():
135
+ pending.future.cancel()
136
+ del self._pending[key]
137
+ for key in [key for key in self._decided if key[0] == run_id]:
138
+ del self._decided[key]