boundflow 0.2.0__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {boundflow-0.2.0 → boundflow-0.3.0}/PKG-INFO +3 -1
- {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/__init__.py +10 -3
- {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/_transport.py +6 -0
- {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/cli/commands/policies.py +30 -1
- {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/cli/commands/workflows.py +27 -1
- {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/control_plane.py +195 -6
- {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/lifecycle.py +1 -0
- {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/llm.py +15 -1
- {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/policies.py +4 -1
- {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/trace.py +7 -0
- boundflow-0.3.0/boundflow/v1/agent_policy_pb2.py +53 -0
- boundflow-0.3.0/boundflow/v1/lifecycle_pb2.py +171 -0
- {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/v1/lifecycle_pb2_grpc.py +218 -0
- boundflow-0.3.0/boundflow/v1/operation_pb2.py +70 -0
- boundflow-0.3.0/boundflow/v1/workflow_pb2.py +49 -0
- {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/worker.py +131 -23
- {boundflow-0.2.0 → boundflow-0.3.0}/boundflow.egg-info/PKG-INFO +3 -1
- {boundflow-0.2.0 → boundflow-0.3.0}/boundflow.egg-info/SOURCES.txt +9 -0
- {boundflow-0.2.0 → boundflow-0.3.0}/boundflow.egg-info/requires.txt +2 -0
- {boundflow-0.2.0 → boundflow-0.3.0}/pyproject.toml +3 -1
- boundflow-0.3.0/tests/test_agent_call_timeout.py +80 -0
- boundflow-0.3.0/tests/test_agent_metrics_latency.py +27 -0
- {boundflow-0.2.0 → boundflow-0.3.0}/tests/test_approval_gate.py +4 -0
- boundflow-0.3.0/tests/test_await_input.py +202 -0
- boundflow-0.3.0/tests/test_complete_result.py +154 -0
- boundflow-0.3.0/tests/test_invoke_context.py +119 -0
- boundflow-0.3.0/tests/test_invoke_mode.py +142 -0
- boundflow-0.3.0/tests/test_pending_approval.py +87 -0
- boundflow-0.3.0/tests/test_policy_getters.py +58 -0
- boundflow-0.3.0/tests/test_request_info.py +52 -0
- boundflow-0.2.0/boundflow/v1/agent_policy_pb2.py +0 -53
- boundflow-0.2.0/boundflow/v1/lifecycle_pb2.py +0 -147
- boundflow-0.2.0/boundflow/v1/operation_pb2.py +0 -68
- boundflow-0.2.0/boundflow/v1/workflow_pb2.py +0 -42
- {boundflow-0.2.0 → boundflow-0.3.0}/LICENSE +0 -0
- {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/anthropic_client.py +0 -0
- {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/cli/__init__.py +0 -0
- {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/cli/__main__.py +0 -0
- {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/cli/_client.py +0 -0
- {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/cli/_output.py +0 -0
- {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/cli/commands/__init__.py +0 -0
- {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/cli/commands/audit.py +0 -0
- {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/cli/commands/pricing.py +0 -0
- {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/cli/commands/tenants.py +0 -0
- {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/errors.py +0 -0
- {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/examples/__init__.py +0 -0
- {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/examples/approval_gate.py +0 -0
- {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/examples/hello.py +0 -0
- {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/examples/langchain_adapter.py +0 -0
- {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/examples/langgraph_workflow.py +0 -0
- {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/examples/model_switching.py +0 -0
- {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/examples/runtime_caps.py +0 -0
- {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/examples/self_healing.py +0 -0
- {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/langchain_client.py +0 -0
- {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/v1/__init__.py +0 -0
- {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/v1/agent_policy_pb2_grpc.py +0 -0
- {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/v1/operation_pb2_grpc.py +0 -0
- {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/v1/policy_pb2.py +0 -0
- {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/v1/policy_pb2_grpc.py +0 -0
- {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/v1/pricing_pb2.py +0 -0
- {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/v1/pricing_pb2_grpc.py +0 -0
- {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/v1/registration_pb2.py +0 -0
- {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/v1/registration_pb2_grpc.py +0 -0
- {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/v1/tenant_group_pb2.py +0 -0
- {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/v1/tenant_group_pb2_grpc.py +0 -0
- {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/v1/tenant_pb2.py +0 -0
- {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/v1/tenant_pb2_grpc.py +0 -0
- {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/v1/worker_pb2.py +0 -0
- {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/v1/worker_pb2_grpc.py +0 -0
- {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/v1/workflow_pb2_grpc.py +0 -0
- {boundflow-0.2.0 → boundflow-0.3.0}/boundflow.egg-info/dependency_links.txt +0 -0
- {boundflow-0.2.0 → boundflow-0.3.0}/boundflow.egg-info/entry_points.txt +0 -0
- {boundflow-0.2.0 → boundflow-0.3.0}/boundflow.egg-info/top_level.txt +0 -0
- {boundflow-0.2.0 → boundflow-0.3.0}/setup.cfg +0 -0
- {boundflow-0.2.0 → boundflow-0.3.0}/tests/test_agent_lifecycle_policy.py +0 -0
- {boundflow-0.2.0 → boundflow-0.3.0}/tests/test_agent_policy_audit.py +0 -0
- {boundflow-0.2.0 → boundflow-0.3.0}/tests/test_approval_audit.py +0 -0
- {boundflow-0.2.0 → boundflow-0.3.0}/tests/test_capability_routing.py +0 -0
- {boundflow-0.2.0 → boundflow-0.3.0}/tests/test_cooldown_and_resume.py +0 -0
- {boundflow-0.2.0 → boundflow-0.3.0}/tests/test_get_workflow.py +0 -0
- {boundflow-0.2.0 → boundflow-0.3.0}/tests/test_langchain_client.py +0 -0
- {boundflow-0.2.0 → boundflow-0.3.0}/tests/test_lifecycle_states.py +0 -0
- {boundflow-0.2.0 → boundflow-0.3.0}/tests/test_list_tenants.py +0 -0
- {boundflow-0.2.0 → boundflow-0.3.0}/tests/test_list_workflows.py +0 -0
- {boundflow-0.2.0 → boundflow-0.3.0}/tests/test_max_tokens_per_call.py +0 -0
- {boundflow-0.2.0 → boundflow-0.3.0}/tests/test_mock_llm.py +0 -0
- {boundflow-0.2.0 → boundflow-0.3.0}/tests/test_model_pricing.py +0 -0
- {boundflow-0.2.0 → boundflow-0.3.0}/tests/test_otel_sink.py +0 -0
- {boundflow-0.2.0 → boundflow-0.3.0}/tests/test_periodic_workflow.py +0 -0
- {boundflow-0.2.0 → boundflow-0.3.0}/tests/test_policy_audit.py +0 -0
- {boundflow-0.2.0 → boundflow-0.3.0}/tests/test_resolve_interrupted_workflow.py +0 -0
- {boundflow-0.2.0 → boundflow-0.3.0}/tests/test_tool_call_limits.py +0 -0
- {boundflow-0.2.0 → boundflow-0.3.0}/tests/test_trace.py +0 -0
- {boundflow-0.2.0 → boundflow-0.3.0}/tests/test_workflow_lifecycle_policy.py +0 -0
- {boundflow-0.2.0 → boundflow-0.3.0}/tests/test_workflow_runs.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: boundflow
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: BoundFlow Python SDK — build governed fleets of agents against a self-hostable control plane.
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Keywords: agents,llm,orchestration,control-plane,governance
|
|
@@ -18,6 +18,8 @@ Requires-Dist: pytest; extra == "dev"
|
|
|
18
18
|
Requires-Dist: pytest-asyncio; extra == "dev"
|
|
19
19
|
Requires-Dist: langchain-core>=0.3; extra == "dev"
|
|
20
20
|
Requires-Dist: langchain-anthropic>=0.3; extra == "dev"
|
|
21
|
+
Requires-Dist: opentelemetry-sdk; extra == "dev"
|
|
22
|
+
Requires-Dist: opentelemetry-exporter-otlp; extra == "dev"
|
|
21
23
|
Provides-Extra: langchain
|
|
22
24
|
Requires-Dist: langchain-core>=0.3; extra == "langchain"
|
|
23
25
|
Provides-Extra: otel
|
|
@@ -5,10 +5,15 @@ from .control_plane import (
|
|
|
5
5
|
ControlPlaneClient,
|
|
6
6
|
ApprovalAuditRecord,
|
|
7
7
|
ApprovalDecision,
|
|
8
|
+
InputAuditRecord,
|
|
9
|
+
InputDecision,
|
|
8
10
|
PolicyActionRecord,
|
|
9
11
|
WorkflowPolicyAction,
|
|
10
12
|
AgentPolicyActionRecord,
|
|
13
|
+
InvokeMode,
|
|
11
14
|
LifecycleState,
|
|
15
|
+
PendingApproval,
|
|
16
|
+
PendingInput,
|
|
12
17
|
RequestInfo,
|
|
13
18
|
Run,
|
|
14
19
|
RunOutcome,
|
|
@@ -32,7 +37,7 @@ from .errors import (
|
|
|
32
37
|
UnauthenticatedError,
|
|
33
38
|
UnavailableError,
|
|
34
39
|
)
|
|
35
|
-
from .llm import MockLlmClient, MockContext, Turn, turn, submit
|
|
40
|
+
from .llm import AgentCallTimeout, MockLlmClient, MockContext, Turn, turn, submit
|
|
36
41
|
from .trace import (
|
|
37
42
|
AgentRunTrace,
|
|
38
43
|
JsonlFileTraceSink,
|
|
@@ -62,8 +67,10 @@ from .worker import (
|
|
|
62
67
|
AgentDefinition,
|
|
63
68
|
ApprovalRequest,
|
|
64
69
|
AwaitApproval,
|
|
70
|
+
AwaitInput,
|
|
65
71
|
BoundFlowWorker,
|
|
66
72
|
Complete,
|
|
73
|
+
InputRequest,
|
|
67
74
|
Next,
|
|
68
75
|
OperationContext,
|
|
69
76
|
OperationResult,
|
|
@@ -75,11 +82,11 @@ __all__ = [
|
|
|
75
82
|
"AnthropicLlmClient",
|
|
76
83
|
"ControlPlaneClient", "LifecycleState", "RunStatus", "RunOutcome", "Run", "RequestInfo",
|
|
77
84
|
"Tenant", "TenantGroup", "Workflow",
|
|
78
|
-
"WorkflowConfig", "WorkflowState", "WorkflowInfo", "ApprovalAuditRecord", "ApprovalDecision", "PolicyActionRecord", "WorkflowPolicyAction", "AgentPolicyActionRecord", "MockLlmClient", "MockContext", "Turn",
|
|
85
|
+
"WorkflowConfig", "WorkflowState", "WorkflowInfo", "PendingApproval", "PendingInput", "InvokeMode", "ApprovalAuditRecord", "ApprovalDecision", "InputAuditRecord", "InputDecision", "PolicyActionRecord", "WorkflowPolicyAction", "AgentPolicyActionRecord", "AgentCallTimeout", "MockLlmClient", "MockContext", "Turn",
|
|
79
86
|
"turn", "submit", "AgentMetric", "AgentRule", "Cooldown", "Op", "Pause",
|
|
80
87
|
"RuntimePolicy", "SetMaxCostUsd", "SetMaxLlmCalls", "SetMaxTokensPerCall",
|
|
81
88
|
"SetModel", "SetVersion", "ToolCallLimit", "WorkflowMetric", "WorkflowRule",
|
|
82
|
-
"AgentDefinition", "ApprovalRequest", "AwaitApproval", "BoundFlowWorker",
|
|
89
|
+
"AgentDefinition", "ApprovalRequest", "AwaitApproval", "AwaitInput", "InputRequest", "BoundFlowWorker",
|
|
83
90
|
"Complete", "Next", "OperationContext", "OperationResult", "Tool", "tool",
|
|
84
91
|
"AgentRunTrace", "OperationTrace", "Span", "TraceSink", "LoggingTraceSink",
|
|
85
92
|
"JsonlFileTraceSink", "OTelTraceSink",
|
|
@@ -50,11 +50,16 @@ def new_approval_id() -> str:
|
|
|
50
50
|
return str(uuid.uuid4())
|
|
51
51
|
|
|
52
52
|
|
|
53
|
+
def new_input_id() -> str:
|
|
54
|
+
return str(uuid.uuid4())
|
|
55
|
+
|
|
56
|
+
|
|
53
57
|
def metrics_to_proto(snapshot: dict) -> op_pb.AgentInvocationMetrics:
|
|
54
58
|
m = op_pb.AgentInvocationMetrics(
|
|
55
59
|
cost_usd=snapshot.get("cost_usd", 0.0),
|
|
56
60
|
llm_calls=snapshot.get("llm_calls", 0),
|
|
57
61
|
tokens_used=snapshot.get("tokens_used", 0),
|
|
62
|
+
latency_seconds=snapshot.get("latency_seconds", 0.0),
|
|
58
63
|
ran_at=snapshot.get("ran_at", 0),
|
|
59
64
|
)
|
|
60
65
|
for tool, count in (snapshot.get("calls_per_tool") or {}).items():
|
|
@@ -89,6 +94,7 @@ def _runtime_policy_to_proto(p) -> ap_pb.AgentRuntimePolicy:
|
|
|
89
94
|
max_llm_calls=p.max_llm_calls,
|
|
90
95
|
max_cost_usd=p.max_cost_usd,
|
|
91
96
|
max_tokens_per_call=p.max_tokens_per_call,
|
|
97
|
+
max_call_seconds=p.max_call_seconds,
|
|
92
98
|
tool_call_limits=[ap_pb.ToolCallLimit(tool=l.tool, max_calls=l.max_calls) for l in p.tool_call_limits],
|
|
93
99
|
)
|
|
94
100
|
|
|
@@ -7,7 +7,7 @@ from typing import List, Optional
|
|
|
7
7
|
import typer
|
|
8
8
|
|
|
9
9
|
from boundflow.cli._client import cp_call
|
|
10
|
-
from boundflow.cli._output import success
|
|
10
|
+
from boundflow.cli._output import output, success
|
|
11
11
|
from boundflow.policies import AgentRule, RuntimePolicy, ToolCallLimit, WorkflowRule
|
|
12
12
|
|
|
13
13
|
app = typer.Typer(help="Manage agent and workflow policies.")
|
|
@@ -65,6 +65,16 @@ def runtime_set(
|
|
|
65
65
|
success(f"Runtime policy set on {workflow_id} / {agent_name}.")
|
|
66
66
|
|
|
67
67
|
|
|
68
|
+
@app.command("get-runtime")
|
|
69
|
+
def runtime_get(
|
|
70
|
+
workflow_id: str = typer.Argument(..., help="Workflow ID"),
|
|
71
|
+
agent_name: str = typer.Argument(..., help="Agent name"),
|
|
72
|
+
):
|
|
73
|
+
"""Show the armed runtime (hard cap) policy for an agent (empty if none is set)."""
|
|
74
|
+
policy = cp_call(lambda cp: cp.get_agent_runtime_policy(workflow_id, agent_name))
|
|
75
|
+
output(policy or {})
|
|
76
|
+
|
|
77
|
+
|
|
68
78
|
@lifecycle_app.command("set-agent")
|
|
69
79
|
def lifecycle_set_agent(
|
|
70
80
|
workflow_id: str = typer.Argument(..., help="Workflow ID"),
|
|
@@ -98,3 +108,22 @@ def lifecycle_set_workflow(
|
|
|
98
108
|
wf_rules = [WorkflowRule.model_validate(r) for r in raw]
|
|
99
109
|
cp_call(lambda cp: cp.set_workflow_lifecycle_policy(workflow_id, wf_rules))
|
|
100
110
|
success(f"Workflow lifecycle policy set on {workflow_id}.")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@lifecycle_app.command("get-agent")
|
|
114
|
+
def lifecycle_get_agent(
|
|
115
|
+
workflow_id: str = typer.Argument(..., help="Workflow ID"),
|
|
116
|
+
agent_name: str = typer.Argument(..., help="Agent name"),
|
|
117
|
+
):
|
|
118
|
+
"""Show the armed lifecycle rules for an agent (empty if none is set)."""
|
|
119
|
+
policy = cp_call(lambda cp: cp.get_agent_lifecycle_policy(workflow_id, agent_name))
|
|
120
|
+
output(policy or {})
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@lifecycle_app.command("get-workflow")
|
|
124
|
+
def lifecycle_get_workflow(
|
|
125
|
+
workflow_id: str = typer.Argument(..., help="Workflow ID"),
|
|
126
|
+
):
|
|
127
|
+
"""Show the armed lifecycle rules for a workflow (empty if none is set)."""
|
|
128
|
+
rules = cp_call(lambda cp: cp.get_workflow_lifecycle_policy(workflow_id))
|
|
129
|
+
output([r.model_dump(mode="json") for r in rules])
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
"""boundflow workflow — workflow lifecycle commands."""
|
|
2
2
|
|
|
3
|
+
import json
|
|
3
4
|
from typing import Optional
|
|
4
5
|
|
|
5
6
|
import typer
|
|
6
7
|
|
|
7
8
|
from boundflow.cli._client import cp_call
|
|
8
9
|
from boundflow.cli._output import output, success
|
|
9
|
-
from boundflow.control_plane import WorkflowConfig
|
|
10
|
+
from boundflow.control_plane import InvokeMode, WorkflowConfig
|
|
10
11
|
|
|
11
12
|
app = typer.Typer(help="Manage workflows.")
|
|
12
13
|
|
|
@@ -19,6 +20,12 @@ def create(
|
|
|
19
20
|
timeout: int = typer.Option(60, "--timeout", help="Invoke timeout in seconds"),
|
|
20
21
|
repeat: int = typer.Option(0, "--repeat", help="Repeat every N seconds (0 = no repeat)"),
|
|
21
22
|
no_triggerable: bool = typer.Option(False, "--no-triggerable", help="Disable manual triggering"),
|
|
23
|
+
invoke_mode: InvokeMode = typer.Option(
|
|
24
|
+
InvokeMode.COALESCE, "--invoke-mode",
|
|
25
|
+
help="How piled-up invokes are handled: coalesce (latest-wins) or queue (run each, FIFO)"),
|
|
26
|
+
max_queue_depth: int = typer.Option(
|
|
27
|
+
0, "--max-queue-depth",
|
|
28
|
+
help="Queue-mode backlog cap; invokes past it are rejected (0 = server default)"),
|
|
22
29
|
):
|
|
23
30
|
"""Create a new workflow."""
|
|
24
31
|
config = WorkflowConfig(
|
|
@@ -26,6 +33,8 @@ def create(
|
|
|
26
33
|
invoke_timeout_seconds=timeout,
|
|
27
34
|
repeat_every_seconds=repeat,
|
|
28
35
|
triggerable=not no_triggerable,
|
|
36
|
+
invoke_mode=invoke_mode,
|
|
37
|
+
max_queue_depth=max_queue_depth,
|
|
29
38
|
)
|
|
30
39
|
result = cp_call(lambda cp: cp.create_workflow(workflow_type, tenant_id, config=config))
|
|
31
40
|
output(result)
|
|
@@ -116,6 +125,23 @@ def reject(
|
|
|
116
125
|
success(f"Approval {approval_id} rejected.")
|
|
117
126
|
|
|
118
127
|
|
|
128
|
+
@app.command("submit-input")
|
|
129
|
+
def submit_input(
|
|
130
|
+
workflow_id: str = typer.Argument(..., help="Workflow ID"),
|
|
131
|
+
input_id: str = typer.Argument(..., help="Input ID (from the input request)"),
|
|
132
|
+
answer: str = typer.Option(..., "--answer", help="Answer as a JSON object, e.g. '{\"choice\": \"refund\"}'"),
|
|
133
|
+
actor: str = typer.Option("", "--actor", help="Who supplied the answer (e.g. email or user ID)"),
|
|
134
|
+
):
|
|
135
|
+
"""Answer a pending workflow input gate."""
|
|
136
|
+
try:
|
|
137
|
+
parsed = json.loads(answer)
|
|
138
|
+
except json.JSONDecodeError as e:
|
|
139
|
+
typer.echo(f"Error: --answer must be valid JSON: {e}", err=True)
|
|
140
|
+
raise typer.Exit(1)
|
|
141
|
+
cp_call(lambda cp: cp.submit_input(workflow_id, input_id, parsed, actor=actor))
|
|
142
|
+
success(f"Input {input_id} submitted.")
|
|
143
|
+
|
|
144
|
+
|
|
119
145
|
@app.command("delete")
|
|
120
146
|
def delete(
|
|
121
147
|
workflow_id: str = typer.Argument(..., help="Workflow ID"),
|
|
@@ -12,6 +12,7 @@ from datetime import datetime
|
|
|
12
12
|
from enum import Enum
|
|
13
13
|
|
|
14
14
|
import grpc
|
|
15
|
+
from google.protobuf.json_format import MessageToDict
|
|
15
16
|
from google.protobuf.struct_pb2 import Struct
|
|
16
17
|
|
|
17
18
|
from boundflow.v1 import agent_policy_pb2 as ap
|
|
@@ -43,12 +44,22 @@ class Tenant:
|
|
|
43
44
|
tenant_group_id: str
|
|
44
45
|
|
|
45
46
|
|
|
47
|
+
class InvokeMode(str, Enum):
|
|
48
|
+
"""How piled-up invokes are handled for a workflow (WorkflowConfig.invoke_mode)."""
|
|
49
|
+
COALESCE = "coalesce" # latest-wins: a newer invoke supersedes older pending ones
|
|
50
|
+
QUEUE = "queue" # fan-in: every invoke runs, drained oldest-first (FIFO)
|
|
51
|
+
|
|
52
|
+
|
|
46
53
|
@dataclass
|
|
47
54
|
class WorkflowConfig:
|
|
48
55
|
version: int = 0
|
|
49
56
|
invoke_timeout_seconds: int = 60
|
|
50
57
|
repeat_every_seconds: int = 0
|
|
51
58
|
triggerable: bool = True
|
|
59
|
+
# max_queue_depth (0 = server default) bounds the queue-mode backlog; ignored in
|
|
60
|
+
# coalesce mode.
|
|
61
|
+
invoke_mode: InvokeMode = InvokeMode.COALESCE
|
|
62
|
+
max_queue_depth: int = 0
|
|
52
63
|
|
|
53
64
|
|
|
54
65
|
@dataclass
|
|
@@ -66,6 +77,7 @@ class LifecycleState(str, Enum):
|
|
|
66
77
|
BLOCKED = "blocked"
|
|
67
78
|
INVOKING = "invoking"
|
|
68
79
|
AWAITING_APPROVAL = "awaiting_approval"
|
|
80
|
+
AWAITING_INPUT = "awaiting_input"
|
|
69
81
|
DELETING = "deleting"
|
|
70
82
|
DELETED = "deleted"
|
|
71
83
|
INTERRUPTED = "interrupted"
|
|
@@ -109,6 +121,13 @@ class ApprovalDecision(str, Enum):
|
|
|
109
121
|
TIMED_OUT = "timed_out"
|
|
110
122
|
|
|
111
123
|
|
|
124
|
+
class InputDecision(str, Enum):
|
|
125
|
+
"""How an input gate was resolved (InputAuditRecord.decision)."""
|
|
126
|
+
UNSPECIFIED = "unspecified"
|
|
127
|
+
ANSWERED = "answered"
|
|
128
|
+
TIMED_OUT = "timed_out"
|
|
129
|
+
|
|
130
|
+
|
|
112
131
|
class WorkflowPolicyAction(str, Enum):
|
|
113
132
|
"""The action a workflow-lifecycle rule took (PolicyActionRecord.action)."""
|
|
114
133
|
UNSPECIFIED = "unspecified"
|
|
@@ -117,10 +136,39 @@ class WorkflowPolicyAction(str, Enum):
|
|
|
117
136
|
PAUSE = "pause"
|
|
118
137
|
|
|
119
138
|
|
|
139
|
+
@dataclass
|
|
140
|
+
class PendingApproval:
|
|
141
|
+
"""The currently open approval gate for a workflow (WorkflowInfo.pending_approval,
|
|
142
|
+
populated only while lifecycle_state is AWAITING_APPROVAL). approval_id is what
|
|
143
|
+
approve_workflow/reject_workflow expect — this is how an external caller (e.g. a
|
|
144
|
+
page reload with no in-process on_approval_requested state) discovers it."""
|
|
145
|
+
approval_id: str
|
|
146
|
+
justification: str
|
|
147
|
+
metadata: dict
|
|
148
|
+
opened_at: datetime | None
|
|
149
|
+
timeout_at: datetime | None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@dataclass
|
|
153
|
+
class PendingInput:
|
|
154
|
+
"""The currently open input gate for a workflow (WorkflowInfo.pending_input,
|
|
155
|
+
populated only while lifecycle_state is AWAITING_INPUT). input_id is what
|
|
156
|
+
submit_input expects — this is how an external caller (e.g. a page reload with
|
|
157
|
+
no in-process on_input_requested state) discovers it."""
|
|
158
|
+
input_id: str
|
|
159
|
+
prompt: str
|
|
160
|
+
metadata: dict
|
|
161
|
+
opened_at: datetime | None
|
|
162
|
+
timeout_at: datetime | None
|
|
163
|
+
|
|
164
|
+
|
|
120
165
|
@dataclass
|
|
121
166
|
class WorkflowInfo:
|
|
122
167
|
"""A read-only view of a workflow — its current version, lifecycle state, and
|
|
123
|
-
workflow state. Returned by `get_workflow` (one) and `list_workflows` (all).
|
|
168
|
+
workflow state. Returned by `get_workflow` (one) and `list_workflows` (all).
|
|
169
|
+
pending_approval/pending_input are only populated by `get_workflow` (None from
|
|
170
|
+
`list_workflows`, which returns a lighter view) and only while lifecycle_state
|
|
171
|
+
is AWAITING_APPROVAL / AWAITING_INPUT respectively."""
|
|
124
172
|
id: str
|
|
125
173
|
workflow_type: str
|
|
126
174
|
tenant_id: str
|
|
@@ -128,6 +176,8 @@ class WorkflowInfo:
|
|
|
128
176
|
workflow_state: WorkflowState
|
|
129
177
|
version: int
|
|
130
178
|
last_interrupted_request_id: str
|
|
179
|
+
pending_approval: PendingApproval | None = None
|
|
180
|
+
pending_input: PendingInput | None = None
|
|
131
181
|
|
|
132
182
|
|
|
133
183
|
def _workflow_info(w) -> WorkflowInfo:
|
|
@@ -139,6 +189,30 @@ def _workflow_info(w) -> WorkflowInfo:
|
|
|
139
189
|
workflow_state=_WF_STATE.get(w.workflow_state, WorkflowState.UNSPECIFIED),
|
|
140
190
|
version=w.workflow_config.version,
|
|
141
191
|
last_interrupted_request_id=w.last_interrupted_request_id,
|
|
192
|
+
pending_approval=_pending_approval(w) if w.HasField("pending_approval") else None,
|
|
193
|
+
pending_input=_pending_input(w) if w.HasField("pending_input") else None,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _pending_approval(w) -> PendingApproval:
|
|
198
|
+
p = w.pending_approval
|
|
199
|
+
return PendingApproval(
|
|
200
|
+
approval_id=p.approval_id,
|
|
201
|
+
justification=p.justification,
|
|
202
|
+
metadata=MessageToDict(p.metadata) if p.HasField("metadata") else {},
|
|
203
|
+
opened_at=_ts(p, "opened_at"),
|
|
204
|
+
timeout_at=_ts(p, "timeout_at"),
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _pending_input(w) -> PendingInput:
|
|
209
|
+
p = w.pending_input
|
|
210
|
+
return PendingInput(
|
|
211
|
+
input_id=p.input_id,
|
|
212
|
+
prompt=p.prompt,
|
|
213
|
+
metadata=MessageToDict(p.metadata) if p.HasField("metadata") else {},
|
|
214
|
+
opened_at=_ts(p, "opened_at"),
|
|
215
|
+
timeout_at=_ts(p, "timeout_at"),
|
|
142
216
|
)
|
|
143
217
|
|
|
144
218
|
|
|
@@ -162,7 +236,11 @@ class RequestInfo:
|
|
|
162
236
|
"""Full state of one run, from get_request_info(request_id). `status` is the run's
|
|
163
237
|
lifecycle (`RunStatus`); `run_outcome` is the terminal `RunOutcome`, or None until
|
|
164
238
|
the run is terminal. sequence_number orders a workflow's runs (monotonic per
|
|
165
|
-
workflow).
|
|
239
|
+
workflow). `result` is the run's published output (Complete(result=...)), or None
|
|
240
|
+
if the run hasn't completed or didn't publish one. invoke_context, timeout_seconds,
|
|
241
|
+
and agent_runtime_policies are what was actually resolved for this specific run —
|
|
242
|
+
useful when workflow/agent config has since changed and you want to know what was
|
|
243
|
+
true at the time this run happened."""
|
|
166
244
|
request_id: str
|
|
167
245
|
workflow_id: str
|
|
168
246
|
request_type: str
|
|
@@ -172,6 +250,10 @@ class RequestInfo:
|
|
|
172
250
|
sequence_number: int
|
|
173
251
|
created_at: datetime | None
|
|
174
252
|
completed_at: datetime | None
|
|
253
|
+
result: dict | None
|
|
254
|
+
invoke_context: dict | None
|
|
255
|
+
timeout_seconds: int
|
|
256
|
+
agent_runtime_policies: dict | None
|
|
175
257
|
|
|
176
258
|
|
|
177
259
|
@dataclass
|
|
@@ -189,6 +271,23 @@ class ApprovalAuditRecord:
|
|
|
189
271
|
occurred_at: datetime | None # when the decision was recorded
|
|
190
272
|
|
|
191
273
|
|
|
274
|
+
@dataclass
|
|
275
|
+
class InputAuditRecord:
|
|
276
|
+
"""One input decision from the audit log. Correlate with a run trace via
|
|
277
|
+
input_id (the trace's boundflow.input_id). answer is the submitted content
|
|
278
|
+
(unset on a timeout decision) — recorded here because it's the governance-
|
|
279
|
+
relevant content, same reasoning as auditing an approval decision."""
|
|
280
|
+
workflow_id: str
|
|
281
|
+
request_id: str
|
|
282
|
+
input_id: str
|
|
283
|
+
decision: InputDecision
|
|
284
|
+
opened_at: datetime | None
|
|
285
|
+
decided_at: datetime | None
|
|
286
|
+
actor: str # customer-supplied; empty for timeouts
|
|
287
|
+
occurred_at: datetime | None # when the decision was recorded
|
|
288
|
+
answer: dict | None # unset on a timeout decision
|
|
289
|
+
|
|
290
|
+
|
|
192
291
|
@dataclass
|
|
193
292
|
class PolicyActionRecord:
|
|
194
293
|
"""One workflow-lifecycle policy firing. Self-describing: the rule that fired
|
|
@@ -229,6 +328,10 @@ _APPROVAL_DECISION = {
|
|
|
229
328
|
lc.APPROVAL_DECISION_REJECTED: ApprovalDecision.REJECTED,
|
|
230
329
|
lc.APPROVAL_DECISION_TIMED_OUT: ApprovalDecision.TIMED_OUT,
|
|
231
330
|
}
|
|
331
|
+
_INPUT_DECISION = {
|
|
332
|
+
lc.INPUT_DECISION_ANSWERED: InputDecision.ANSWERED,
|
|
333
|
+
lc.INPUT_DECISION_TIMED_OUT: InputDecision.TIMED_OUT,
|
|
334
|
+
}
|
|
232
335
|
_WORKFLOW_METRIC = {
|
|
233
336
|
lc.WORKFLOW_METRIC_NUM_FAILURES: "num_failures",
|
|
234
337
|
lc.WORKFLOW_METRIC_COST: "cost",
|
|
@@ -275,6 +378,15 @@ def _approval_record(r) -> ApprovalAuditRecord:
|
|
|
275
378
|
actor=r.actor, occurred_at=_ts(r, "occurred_at"))
|
|
276
379
|
|
|
277
380
|
|
|
381
|
+
def _input_record(r) -> InputAuditRecord:
|
|
382
|
+
return InputAuditRecord(
|
|
383
|
+
workflow_id=r.workflow_id, request_id=r.request_id, input_id=r.input_id,
|
|
384
|
+
decision=_INPUT_DECISION.get(r.decision, InputDecision.UNSPECIFIED),
|
|
385
|
+
opened_at=_ts(r, "opened_at"), decided_at=_ts(r, "decided_at"),
|
|
386
|
+
actor=r.actor, occurred_at=_ts(r, "occurred_at"),
|
|
387
|
+
answer=MessageToDict(r.answer) if r.HasField("answer") else None)
|
|
388
|
+
|
|
389
|
+
|
|
278
390
|
def _workflow_policy_record(r) -> PolicyActionRecord:
|
|
279
391
|
act = r.rule.action
|
|
280
392
|
return PolicyActionRecord(
|
|
@@ -324,6 +436,7 @@ _LIFECYCLE = {
|
|
|
324
436
|
"blocked": LifecycleState.BLOCKED,
|
|
325
437
|
"invoking": LifecycleState.INVOKING,
|
|
326
438
|
"awaiting_approval": LifecycleState.AWAITING_APPROVAL,
|
|
439
|
+
"awaiting_input": LifecycleState.AWAITING_INPUT,
|
|
327
440
|
"deleting": LifecycleState.DELETING,
|
|
328
441
|
"deleted": LifecycleState.DELETED,
|
|
329
442
|
"interrupted": LifecycleState.INTERRUPTED,
|
|
@@ -344,6 +457,7 @@ _WF_METRIC = {
|
|
|
344
457
|
WorkflowMetric.APPROVAL_REJECTIONS: lc.WORKFLOW_METRIC_APPROVAL_REJECTIONS,
|
|
345
458
|
WorkflowMetric.TOOL_FAILURE_RATE: lc.WORKFLOW_METRIC_TOOL_FAILURE_RATE,
|
|
346
459
|
}
|
|
460
|
+
_WF_METRIC_REV = {v: k for k, v in _WF_METRIC.items()}
|
|
347
461
|
|
|
348
462
|
|
|
349
463
|
def _struct(d: dict) -> Struct:
|
|
@@ -479,12 +593,17 @@ class ControlPlaneClient:
|
|
|
479
593
|
invoke_timeout_seconds=cfg.invoke_timeout_seconds,
|
|
480
594
|
repeat_every_seconds=cfg.repeat_every_seconds,
|
|
481
595
|
triggerable=cfg.triggerable,
|
|
596
|
+
invoke_mode=(ri.INVOKE_MODE_QUEUE if cfg.invoke_mode == InvokeMode.QUEUE
|
|
597
|
+
else ri.INVOKE_MODE_COALESCE),
|
|
598
|
+
max_queue_depth=cfg.max_queue_depth,
|
|
482
599
|
),
|
|
483
600
|
), metadata=self._metadata)
|
|
484
601
|
inst = resp.workflow
|
|
485
602
|
wc = inst.workflow_config
|
|
486
603
|
return Workflow(inst.id, inst.tenant_id, WorkflowConfig(
|
|
487
|
-
wc.version, wc.invoke_timeout_seconds, wc.repeat_every_seconds, wc.triggerable
|
|
604
|
+
wc.version, wc.invoke_timeout_seconds, wc.repeat_every_seconds, wc.triggerable,
|
|
605
|
+
InvokeMode.QUEUE if wc.invoke_mode == ri.INVOKE_MODE_QUEUE else InvokeMode.COALESCE,
|
|
606
|
+
wc.max_queue_depth))
|
|
488
607
|
|
|
489
608
|
async def activate_workflow(self, workflow_id: str) -> None:
|
|
490
609
|
await self._lc.ActivateWorkflow(
|
|
@@ -499,12 +618,18 @@ class ControlPlaneClient:
|
|
|
499
618
|
lc.ResolveInterruptedWorkflowRequest(workflow_id=workflow_id, request_id=request_id),
|
|
500
619
|
metadata=self._metadata)
|
|
501
620
|
|
|
502
|
-
async def invoke_workflow(self, workflow_id: str, *, operation_timeout_seconds: int = 0
|
|
621
|
+
async def invoke_workflow(self, workflow_id: str, *, operation_timeout_seconds: int = 0,
|
|
622
|
+
context: dict | None = None) -> str:
|
|
503
623
|
"""Trigger a run; returns the request_id — the run/trace id you can use to
|
|
504
|
-
find this invocation's trace later.
|
|
624
|
+
find this invocation's trace later.
|
|
625
|
+
|
|
626
|
+
`context` is optional per-run input; operations read and write it via
|
|
627
|
+
`ctx.context` (the caller's own data, kept apart from the runtime's keys in the
|
|
628
|
+
raw job context). Keep it small (input/coordination data, not a datastore)."""
|
|
505
629
|
resp = await self._lc.InvokeWorkflow(lc.InvokeWorkflowRequest(
|
|
506
630
|
workflow_id=workflow_id,
|
|
507
631
|
runtime_overrides=lc.RuntimeOverrides(operation_timeout_seconds=operation_timeout_seconds),
|
|
632
|
+
initial_context=_struct(context) if context else None,
|
|
508
633
|
), metadata=self._metadata)
|
|
509
634
|
return resp.request_id
|
|
510
635
|
|
|
@@ -554,6 +679,10 @@ class ControlPlaneClient:
|
|
|
554
679
|
sequence_number=r.version,
|
|
555
680
|
created_at=_ts(r, "created_at"),
|
|
556
681
|
completed_at=_ts(r, "completed_at"),
|
|
682
|
+
result=MessageToDict(r.result) if r.HasField("result") else None,
|
|
683
|
+
invoke_context=MessageToDict(r.invoke_context) if r.HasField("invoke_context") else None,
|
|
684
|
+
timeout_seconds=r.operation_timeout_seconds,
|
|
685
|
+
agent_runtime_policies=MessageToDict(r.agent_runtime_policies) if r.HasField("agent_runtime_policies") else None,
|
|
557
686
|
)
|
|
558
687
|
|
|
559
688
|
async def approve_workflow(self, workflow_id: str, approval_id: str, actor: str = "") -> None:
|
|
@@ -582,6 +711,21 @@ class ControlPlaneClient:
|
|
|
582
711
|
lc.GetApprovalAuditByIdRequest(approval_id=approval_id), metadata=self._metadata)
|
|
583
712
|
return _approval_record(resp.record) if resp.HasField("record") else None
|
|
584
713
|
|
|
714
|
+
async def submit_input(self, workflow_id: str, input_id: str, answer: dict, actor: str = "") -> None:
|
|
715
|
+
"""Answer a parked input gate. `actor` identifies who supplied the answer
|
|
716
|
+
(e.g. an email or user id); it's recorded in the input audit log (auth is
|
|
717
|
+
tenant-scoped, so the customer's gate is the source of actor identity)."""
|
|
718
|
+
await self._lc.SubmitInput(
|
|
719
|
+
lc.SubmitInputRequest(workflow_id=workflow_id, input_id=input_id,
|
|
720
|
+
answer=_struct(answer), actor=actor),
|
|
721
|
+
metadata=self._metadata)
|
|
722
|
+
|
|
723
|
+
async def get_input_audit(self, workflow_id: str) -> list[InputAuditRecord]:
|
|
724
|
+
"""A workflow's input decisions (newest first)."""
|
|
725
|
+
resp = await self._lc.GetInputAudit(
|
|
726
|
+
lc.GetInputAuditRequest(workflow_id=workflow_id), metadata=self._metadata)
|
|
727
|
+
return [_input_record(r) for r in resp.records]
|
|
728
|
+
|
|
585
729
|
async def get_workflow_policy_audit(self, workflow_id: str) -> list[PolicyActionRecord]:
|
|
586
730
|
"""A workflow's workflow-lifecycle policy firings (newest first)."""
|
|
587
731
|
resp = await self._lc.GetWorkflowPolicyAudit(
|
|
@@ -599,7 +743,7 @@ class ControlPlaneClient:
|
|
|
599
743
|
async def get_audit_log(self, workflow_id: str = ""):
|
|
600
744
|
"""The unified, time-ordered audit log (newest first). workflow_id is optional
|
|
601
745
|
— omit for the whole tenant group. Each item is an ApprovalAuditRecord,
|
|
602
|
-
PolicyActionRecord, or AgentPolicyActionRecord."""
|
|
746
|
+
InputAuditRecord, PolicyActionRecord, or AgentPolicyActionRecord."""
|
|
603
747
|
resp = await self._lc.GetAuditLog(
|
|
604
748
|
lc.GetAuditLogRequest(workflow_id=workflow_id), metadata=self._metadata)
|
|
605
749
|
out = []
|
|
@@ -611,6 +755,8 @@ class ControlPlaneClient:
|
|
|
611
755
|
out.append(_workflow_policy_record(e.workflow_policy))
|
|
612
756
|
elif which == "agent_policy":
|
|
613
757
|
out.append(_agent_policy_record(e.agent_policy))
|
|
758
|
+
elif which == "input":
|
|
759
|
+
out.append(_input_record(e.input))
|
|
614
760
|
return out
|
|
615
761
|
|
|
616
762
|
async def delete_workflow(self, workflow_id: str) -> None:
|
|
@@ -648,6 +794,31 @@ class ControlPlaneClient:
|
|
|
648
794
|
rules=[_workflow_rule_proto(r) for r in rules]),
|
|
649
795
|
), metadata=self._metadata)
|
|
650
796
|
|
|
797
|
+
async def get_workflow_lifecycle_policy(self, workflow_id: str) -> list[WorkflowRule]:
|
|
798
|
+
"""The armed workflow-lifecycle policy — the rules currently configured (the
|
|
799
|
+
inverse of set_workflow_lifecycle_policy). Empty list if none is set. Reflects the
|
|
800
|
+
current config, not a per-run snapshot; capture at run time for an audit receipt."""
|
|
801
|
+
resp = await self._lc.GetWorkflowLifecyclePolicy(
|
|
802
|
+
lc.GetWorkflowLifecyclePolicyRequest(workflow_id=workflow_id),
|
|
803
|
+
metadata=self._metadata)
|
|
804
|
+
return [_workflow_rule_from_proto(r) for r in resp.lifecycle_policy.rules]
|
|
805
|
+
|
|
806
|
+
async def get_agent_runtime_policy(self, workflow_id: str, agent_name: str) -> dict:
|
|
807
|
+
"""The armed runtime policy (hard caps + model override) for one agent, as a dict.
|
|
808
|
+
Empty dict if none is set."""
|
|
809
|
+
resp = await self._lc.GetAgentRuntimePolicy(
|
|
810
|
+
lc.GetAgentRuntimePolicyRequest(workflow_id=workflow_id, agent_name=agent_name),
|
|
811
|
+
metadata=self._metadata)
|
|
812
|
+
return MessageToDict(resp.runtime_policy)
|
|
813
|
+
|
|
814
|
+
async def get_agent_lifecycle_policy(self, workflow_id: str, agent_name: str) -> dict:
|
|
815
|
+
"""The armed lifecycle policy (adaptive rules) for one agent, as a dict. Empty dict
|
|
816
|
+
if none is set."""
|
|
817
|
+
resp = await self._lc.GetAgentLifecyclePolicy(
|
|
818
|
+
lc.GetAgentLifecyclePolicyRequest(workflow_id=workflow_id, agent_name=agent_name),
|
|
819
|
+
metadata=self._metadata)
|
|
820
|
+
return MessageToDict(resp.lifecycle_policy)
|
|
821
|
+
|
|
651
822
|
|
|
652
823
|
def _workflow_rule_proto(rule: WorkflowRule) -> lc.WorkflowLifecyclePolicyRule:
|
|
653
824
|
action = rule.action
|
|
@@ -671,3 +842,21 @@ def _workflow_rule_proto(rule: WorkflowRule) -> lc.WorkflowLifecyclePolicyRule:
|
|
|
671
842
|
tool_name=rule.tool or "",
|
|
672
843
|
action=act,
|
|
673
844
|
)
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
def _workflow_rule_from_proto(r: lc.WorkflowLifecyclePolicyRule) -> WorkflowRule:
|
|
848
|
+
"""Inverse of _workflow_rule_proto: proto rule → WorkflowRule. The rule's window
|
|
849
|
+
lives on the action in the SDK model (Pause/Cooldown), so it's folded back in."""
|
|
850
|
+
t = r.action.type
|
|
851
|
+
if t == lc.WORKFLOW_POLICY_ACTION_COOLDOWN:
|
|
852
|
+
action = Cooldown(window=r.window, seconds=r.action.cooldown_seconds)
|
|
853
|
+
elif t == lc.WORKFLOW_POLICY_ACTION_SET_VERSION:
|
|
854
|
+
action = SetVersion(target=r.action.target_version)
|
|
855
|
+
else:
|
|
856
|
+
action = Pause(window=r.window)
|
|
857
|
+
return WorkflowRule(
|
|
858
|
+
metric=_WF_METRIC_REV[r.metric],
|
|
859
|
+
threshold=r.threshold,
|
|
860
|
+
action=action,
|
|
861
|
+
tool=r.tool_name or None,
|
|
862
|
+
)
|
|
@@ -47,6 +47,7 @@ def load_runtime_policy(node: dict | None) -> RuntimePolicy:
|
|
|
47
47
|
max_llm_calls=node.get("max_llm_calls", 0),
|
|
48
48
|
max_cost_usd=node.get("max_cost_usd", 0),
|
|
49
49
|
max_tokens_per_call=node.get("max_tokens_per_call", 0),
|
|
50
|
+
max_call_seconds=node.get("max_call_seconds", 0),
|
|
50
51
|
tool_call_limits=limits,
|
|
51
52
|
model=node.get("model"),
|
|
52
53
|
)
|
|
@@ -7,6 +7,7 @@ port of BoundFlow.SDK Orchestrator.RunAsync.
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
+
import asyncio
|
|
10
11
|
import logging
|
|
11
12
|
from dataclasses import dataclass, field
|
|
12
13
|
from typing import Any, Callable, Protocol
|
|
@@ -130,6 +131,12 @@ class LlmClient(Protocol):
|
|
|
130
131
|
async def complete(self, request: LlmRequest) -> LlmResponse: ...
|
|
131
132
|
|
|
132
133
|
|
|
134
|
+
class AgentCallTimeout(Exception):
|
|
135
|
+
"""Raised when an LLM call exceeds RuntimePolicy.max_call_seconds. A customer-
|
|
136
|
+
domain failure like any other callback exception — the operation completes
|
|
137
|
+
(marked failed) and the workflow stays active."""
|
|
138
|
+
|
|
139
|
+
|
|
133
140
|
# ── Scripted mock ─────────────────────────────────────────────────────────────
|
|
134
141
|
|
|
135
142
|
|
|
@@ -318,7 +325,14 @@ class Orchestrator:
|
|
|
318
325
|
)
|
|
319
326
|
log.debug("llm_call #%d forced_tool=%s", llm_calls + 1, req.forced_tool)
|
|
320
327
|
_llm_start = now_ms()
|
|
321
|
-
|
|
328
|
+
try:
|
|
329
|
+
if cfg.policy.max_call_seconds > 0:
|
|
330
|
+
resp = await asyncio.wait_for(self._client.complete(req), timeout=cfg.policy.max_call_seconds)
|
|
331
|
+
else:
|
|
332
|
+
resp = await self._client.complete(req)
|
|
333
|
+
except asyncio.TimeoutError:
|
|
334
|
+
raise AgentCallTimeout(
|
|
335
|
+
f"LLM call exceeded max_call_seconds={cfg.policy.max_call_seconds}") from None
|
|
322
336
|
_llm_end = now_ms()
|
|
323
337
|
_input_messages = _gen_ai_input_messages(req) # snapshot as-sent, before appending the reply
|
|
324
338
|
|
|
@@ -21,11 +21,14 @@ class ToolCallLimit(BaseModel):
|
|
|
21
21
|
|
|
22
22
|
|
|
23
23
|
class RuntimePolicy(BaseModel):
|
|
24
|
-
"""Hard caps enforced SDK-side during the agent loop.
|
|
24
|
+
"""Hard caps enforced SDK-side during the agent loop. Unlike the other caps —
|
|
25
|
+
which force a graceful submit_result on the next turn — max_call_seconds cancels
|
|
26
|
+
an in-flight LLM call outright, since a hung call never reaches a next turn."""
|
|
25
27
|
|
|
26
28
|
max_llm_calls: int = 0
|
|
27
29
|
max_cost_usd: float = 0
|
|
28
30
|
max_tokens_per_call: int = 0
|
|
31
|
+
max_call_seconds: float = 0 # 0 = unset (no per-call timeout)
|
|
29
32
|
tool_call_limits: list[ToolCallLimit] = Field(default_factory=list)
|
|
30
33
|
model: str | None = None
|
|
31
34
|
|
|
@@ -59,6 +59,7 @@ BF_OPERATION = "boundflow.operation"
|
|
|
59
59
|
BF_OUTCOME = "boundflow.outcome"
|
|
60
60
|
BF_FAILED = "boundflow.failed"
|
|
61
61
|
BF_APPROVAL_ID = "boundflow.approval_id"
|
|
62
|
+
BF_INPUT_ID = "boundflow.input_id"
|
|
62
63
|
|
|
63
64
|
# ── Vocabulary values (not attribute keys) ────────────────────────────────────
|
|
64
65
|
# GenAI operation-name values (the value of gen_ai.operation.name):
|
|
@@ -81,6 +82,7 @@ SPAN_KIND_TOOL = "tool"
|
|
|
81
82
|
OUTCOME_COMPLETED = "completed"
|
|
82
83
|
OUTCOME_NEXT = "next"
|
|
83
84
|
OUTCOME_AWAIT_APPROVAL = "await_approval"
|
|
85
|
+
OUTCOME_AWAIT_INPUT = "await_input"
|
|
84
86
|
# Generic error attribute keys:
|
|
85
87
|
ERROR = "error"
|
|
86
88
|
ERROR_MESSAGE = "error.message"
|
|
@@ -141,6 +143,9 @@ class OperationTrace:
|
|
|
141
143
|
# actor, and timing server-side via GetApprovalAudit. The decision itself is
|
|
142
144
|
# NOT in the trace by design — it lives in the BoundFlow audit log.
|
|
143
145
|
approval_id: str | None = None
|
|
146
|
+
# Set only when outcome == "await_input": the key to look up the answer, actor,
|
|
147
|
+
# and timing server-side via GetInputAudit. Same reasoning as approval_id.
|
|
148
|
+
input_id: str | None = None
|
|
144
149
|
|
|
145
150
|
def to_dict(self) -> dict:
|
|
146
151
|
return asdict(self)
|
|
@@ -239,6 +244,8 @@ class OTelTraceSink:
|
|
|
239
244
|
op.set_attribute(BF_FAILED, trace.failed)
|
|
240
245
|
if trace.approval_id:
|
|
241
246
|
op.set_attribute(BF_APPROVAL_ID, trace.approval_id)
|
|
247
|
+
if trace.input_id:
|
|
248
|
+
op.set_attribute(BF_INPUT_ID, trace.input_id)
|
|
242
249
|
op_ctx = self._ot.set_span_in_context(op)
|
|
243
250
|
for run in trace.agent_runs:
|
|
244
251
|
agent = self._tracer.start_span(f"agent {run.agent}", context=op_ctx,
|