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.
Files changed (95) hide show
  1. {boundflow-0.2.0 → boundflow-0.3.0}/PKG-INFO +3 -1
  2. {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/__init__.py +10 -3
  3. {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/_transport.py +6 -0
  4. {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/cli/commands/policies.py +30 -1
  5. {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/cli/commands/workflows.py +27 -1
  6. {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/control_plane.py +195 -6
  7. {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/lifecycle.py +1 -0
  8. {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/llm.py +15 -1
  9. {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/policies.py +4 -1
  10. {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/trace.py +7 -0
  11. boundflow-0.3.0/boundflow/v1/agent_policy_pb2.py +53 -0
  12. boundflow-0.3.0/boundflow/v1/lifecycle_pb2.py +171 -0
  13. {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/v1/lifecycle_pb2_grpc.py +218 -0
  14. boundflow-0.3.0/boundflow/v1/operation_pb2.py +70 -0
  15. boundflow-0.3.0/boundflow/v1/workflow_pb2.py +49 -0
  16. {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/worker.py +131 -23
  17. {boundflow-0.2.0 → boundflow-0.3.0}/boundflow.egg-info/PKG-INFO +3 -1
  18. {boundflow-0.2.0 → boundflow-0.3.0}/boundflow.egg-info/SOURCES.txt +9 -0
  19. {boundflow-0.2.0 → boundflow-0.3.0}/boundflow.egg-info/requires.txt +2 -0
  20. {boundflow-0.2.0 → boundflow-0.3.0}/pyproject.toml +3 -1
  21. boundflow-0.3.0/tests/test_agent_call_timeout.py +80 -0
  22. boundflow-0.3.0/tests/test_agent_metrics_latency.py +27 -0
  23. {boundflow-0.2.0 → boundflow-0.3.0}/tests/test_approval_gate.py +4 -0
  24. boundflow-0.3.0/tests/test_await_input.py +202 -0
  25. boundflow-0.3.0/tests/test_complete_result.py +154 -0
  26. boundflow-0.3.0/tests/test_invoke_context.py +119 -0
  27. boundflow-0.3.0/tests/test_invoke_mode.py +142 -0
  28. boundflow-0.3.0/tests/test_pending_approval.py +87 -0
  29. boundflow-0.3.0/tests/test_policy_getters.py +58 -0
  30. boundflow-0.3.0/tests/test_request_info.py +52 -0
  31. boundflow-0.2.0/boundflow/v1/agent_policy_pb2.py +0 -53
  32. boundflow-0.2.0/boundflow/v1/lifecycle_pb2.py +0 -147
  33. boundflow-0.2.0/boundflow/v1/operation_pb2.py +0 -68
  34. boundflow-0.2.0/boundflow/v1/workflow_pb2.py +0 -42
  35. {boundflow-0.2.0 → boundflow-0.3.0}/LICENSE +0 -0
  36. {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/anthropic_client.py +0 -0
  37. {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/cli/__init__.py +0 -0
  38. {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/cli/__main__.py +0 -0
  39. {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/cli/_client.py +0 -0
  40. {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/cli/_output.py +0 -0
  41. {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/cli/commands/__init__.py +0 -0
  42. {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/cli/commands/audit.py +0 -0
  43. {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/cli/commands/pricing.py +0 -0
  44. {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/cli/commands/tenants.py +0 -0
  45. {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/errors.py +0 -0
  46. {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/examples/__init__.py +0 -0
  47. {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/examples/approval_gate.py +0 -0
  48. {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/examples/hello.py +0 -0
  49. {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/examples/langchain_adapter.py +0 -0
  50. {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/examples/langgraph_workflow.py +0 -0
  51. {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/examples/model_switching.py +0 -0
  52. {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/examples/runtime_caps.py +0 -0
  53. {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/examples/self_healing.py +0 -0
  54. {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/langchain_client.py +0 -0
  55. {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/v1/__init__.py +0 -0
  56. {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/v1/agent_policy_pb2_grpc.py +0 -0
  57. {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/v1/operation_pb2_grpc.py +0 -0
  58. {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/v1/policy_pb2.py +0 -0
  59. {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/v1/policy_pb2_grpc.py +0 -0
  60. {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/v1/pricing_pb2.py +0 -0
  61. {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/v1/pricing_pb2_grpc.py +0 -0
  62. {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/v1/registration_pb2.py +0 -0
  63. {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/v1/registration_pb2_grpc.py +0 -0
  64. {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/v1/tenant_group_pb2.py +0 -0
  65. {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/v1/tenant_group_pb2_grpc.py +0 -0
  66. {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/v1/tenant_pb2.py +0 -0
  67. {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/v1/tenant_pb2_grpc.py +0 -0
  68. {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/v1/worker_pb2.py +0 -0
  69. {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/v1/worker_pb2_grpc.py +0 -0
  70. {boundflow-0.2.0 → boundflow-0.3.0}/boundflow/v1/workflow_pb2_grpc.py +0 -0
  71. {boundflow-0.2.0 → boundflow-0.3.0}/boundflow.egg-info/dependency_links.txt +0 -0
  72. {boundflow-0.2.0 → boundflow-0.3.0}/boundflow.egg-info/entry_points.txt +0 -0
  73. {boundflow-0.2.0 → boundflow-0.3.0}/boundflow.egg-info/top_level.txt +0 -0
  74. {boundflow-0.2.0 → boundflow-0.3.0}/setup.cfg +0 -0
  75. {boundflow-0.2.0 → boundflow-0.3.0}/tests/test_agent_lifecycle_policy.py +0 -0
  76. {boundflow-0.2.0 → boundflow-0.3.0}/tests/test_agent_policy_audit.py +0 -0
  77. {boundflow-0.2.0 → boundflow-0.3.0}/tests/test_approval_audit.py +0 -0
  78. {boundflow-0.2.0 → boundflow-0.3.0}/tests/test_capability_routing.py +0 -0
  79. {boundflow-0.2.0 → boundflow-0.3.0}/tests/test_cooldown_and_resume.py +0 -0
  80. {boundflow-0.2.0 → boundflow-0.3.0}/tests/test_get_workflow.py +0 -0
  81. {boundflow-0.2.0 → boundflow-0.3.0}/tests/test_langchain_client.py +0 -0
  82. {boundflow-0.2.0 → boundflow-0.3.0}/tests/test_lifecycle_states.py +0 -0
  83. {boundflow-0.2.0 → boundflow-0.3.0}/tests/test_list_tenants.py +0 -0
  84. {boundflow-0.2.0 → boundflow-0.3.0}/tests/test_list_workflows.py +0 -0
  85. {boundflow-0.2.0 → boundflow-0.3.0}/tests/test_max_tokens_per_call.py +0 -0
  86. {boundflow-0.2.0 → boundflow-0.3.0}/tests/test_mock_llm.py +0 -0
  87. {boundflow-0.2.0 → boundflow-0.3.0}/tests/test_model_pricing.py +0 -0
  88. {boundflow-0.2.0 → boundflow-0.3.0}/tests/test_otel_sink.py +0 -0
  89. {boundflow-0.2.0 → boundflow-0.3.0}/tests/test_periodic_workflow.py +0 -0
  90. {boundflow-0.2.0 → boundflow-0.3.0}/tests/test_policy_audit.py +0 -0
  91. {boundflow-0.2.0 → boundflow-0.3.0}/tests/test_resolve_interrupted_workflow.py +0 -0
  92. {boundflow-0.2.0 → boundflow-0.3.0}/tests/test_tool_call_limits.py +0 -0
  93. {boundflow-0.2.0 → boundflow-0.3.0}/tests/test_trace.py +0 -0
  94. {boundflow-0.2.0 → boundflow-0.3.0}/tests/test_workflow_lifecycle_policy.py +0 -0
  95. {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.2.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) -> str:
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
- resp = await self._client.complete(req)
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,