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.
- {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/PKG-INFO +2 -2
- {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/pyproject.toml +2 -2
- {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/agent_config.py +14 -0
- licos_agent_runtime-0.3.2/src/licos_agent_runtime/agent_graph.py +249 -0
- {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/app.py +106 -2
- licos_agent_runtime-0.3.2/src/licos_agent_runtime/harness_approval.py +138 -0
- licos_agent_runtime-0.3.2/src/licos_agent_runtime/harness_config.py +110 -0
- licos_agent_runtime-0.3.2/src/licos_agent_runtime/harness_execution.py +128 -0
- licos_agent_runtime-0.3.2/src/licos_agent_runtime/harness_planning.py +67 -0
- licos_agent_runtime-0.3.2/src/licos_agent_runtime/harness_verification.py +64 -0
- licos_agent_runtime-0.3.2/src/licos_agent_runtime/openai.py +113 -0
- licos_agent_runtime-0.3.2/src/licos_agent_runtime/project_agent.py +77 -0
- {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/project_files.py +4 -3
- licos_agent_runtime-0.3.2/src/licos_agent_runtime/project_history.py +69 -0
- {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/run_config.py +4 -0
- {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/service.py +155 -23
- licos_agent_runtime-0.3.2/src/licos_agent_runtime/session_memory.py +169 -0
- {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/stream_protocol.py +53 -0
- {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/stream_runner.py +1 -1
- {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/trace_records.py +11 -0
- {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/verify.py +24 -5
- {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/tests/test_execution_errors.py +70 -0
- licos_agent_runtime-0.3.2/tests/test_harness.py +839 -0
- licos_agent_runtime-0.3.2/tests/test_openai.py +92 -0
- {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/tests/test_stream_protocol.py +33 -0
- {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/tests/test_verify.py +16 -1
- licos_agent_runtime-0.3.0/src/licos_agent_runtime/agent_graph.py +0 -115
- licos_agent_runtime-0.3.0/src/licos_agent_runtime/openai.py +0 -98
- licos_agent_runtime-0.3.0/src/licos_agent_runtime/project_agent.py +0 -55
- {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/.gitignore +0 -0
- {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/README.md +0 -0
- {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/__init__.py +0 -0
- {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/__main__.py +0 -0
- {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/agent_canvas.py +0 -0
- {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/agent_input.py +0 -0
- {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/attachments.py +0 -0
- {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/context.py +0 -0
- {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/credit_availability.py +0 -0
- {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/errors.py +0 -0
- {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/event_store.py +0 -0
- {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/event_subscriptions.py +0 -0
- {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/graph_contract.py +0 -0
- {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/graph_loader.py +0 -0
- {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/logging.py +0 -0
- {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/model_identity.py +0 -0
- {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/model_usage.py +0 -0
- {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/observability.py +0 -0
- {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/output_files.py +0 -0
- {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/parser.py +0 -0
- {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/payload.py +0 -0
- {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/project_skills.py +0 -0
- {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/runtime_config.py +0 -0
- {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/session_files.py +0 -0
- {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/telemetry.py +0 -0
- {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/workflow_canvas.py +0 -0
- {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/workflow_protocol.py +0 -0
- {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/tests/test_agent_config.py +0 -0
- {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/tests/test_agent_graph.py +0 -0
- {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/tests/test_app_cors.py +0 -0
- {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/tests/test_attachment_inputs.py +0 -0
- {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/tests/test_context.py +0 -0
- {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/tests/test_credit_availability.py +0 -0
- {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/tests/test_credit_availability_routes.py +0 -0
- {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/tests/test_deployment_layout.py +0 -0
- {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/tests/test_event_delivery_api.py +0 -0
- {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/tests/test_event_subscriptions.py +0 -0
- {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/tests/test_graph_contract.py +0 -0
- {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/tests/test_graph_loader.py +0 -0
- {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/tests/test_model_identity.py +0 -0
- {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/tests/test_model_usage.py +0 -0
- {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/tests/test_observability.py +0 -0
- {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/tests/test_parser.py +0 -0
- {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/tests/test_payload.py +0 -0
- {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/tests/test_project_files.py +0 -0
- {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/tests/test_project_skills.py +0 -0
- {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/tests/test_run_config.py +0 -0
- {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/tests/test_runtime_config.py +0 -0
- {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/tests/test_service_stream_protocol.py +0 -0
- {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/tests/test_static_assets.py +0 -0
- {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/tests/test_stream_runner.py +0 -0
- {licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/tests/test_trace_records.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
25
|
+
"licos-dev-sdk>=0.4.9",
|
|
26
26
|
"filelock>=3.16,<4",
|
|
27
27
|
"PyYAML>=6,<7",
|
|
28
28
|
"wcmatch>=10,<11",
|
{licos_agent_runtime-0.3.0 → licos_agent_runtime-0.3.2}/src/licos_agent_runtime/agent_config.py
RENAMED
|
@@ -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
|
-
|
|
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
|
-
|
|
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]
|