boundflow 0.5.0__tar.gz → 0.7.0.dev7__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 (126) hide show
  1. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/PKG-INFO +11 -1
  2. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/__init__.py +16 -4
  3. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/_transport.py +33 -5
  4. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/cli/__init__.py +17 -2
  5. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/cli/_client.py +7 -1
  6. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/cli/commands/audit.py +12 -3
  7. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/cli/commands/policies.py +39 -1
  8. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/cli/commands/pricing.py +1 -1
  9. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/cli/commands/tenants.py +1 -1
  10. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/cli/commands/workflows.py +45 -4
  11. boundflow-0.5.0/boundflow/cli/_output.py → boundflow-0.7.0.dev7/boundflow/cli/output.py +9 -1
  12. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/control_plane.py +176 -14
  13. boundflow-0.7.0.dev7/boundflow/examples/governed_langgraph.py +120 -0
  14. boundflow-0.7.0.dev7/boundflow/governed.py +601 -0
  15. boundflow-0.7.0.dev7/boundflow/langchain_client.py +529 -0
  16. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/lifecycle.py +40 -13
  17. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/llm.py +83 -77
  18. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/policies.py +28 -3
  19. boundflow-0.7.0.dev7/boundflow/ui/__init__.py +22 -0
  20. boundflow-0.7.0.dev7/boundflow/ui/labels.py +60 -0
  21. boundflow-0.7.0.dev7/boundflow/ui/render.py +328 -0
  22. boundflow-0.7.0.dev7/boundflow/ui/server.py +398 -0
  23. boundflow-0.7.0.dev7/boundflow/ui/views.py +690 -0
  24. boundflow-0.7.0.dev7/boundflow/v1/lifecycle_pb2.py +201 -0
  25. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/v1/lifecycle_pb2_grpc.py +179 -0
  26. boundflow-0.7.0.dev7/boundflow/v1/operation_pb2.py +72 -0
  27. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/v1/worker_pb2.py +4 -4
  28. boundflow-0.7.0.dev7/boundflow/v1/workflow_pb2.py +51 -0
  29. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/worker.py +342 -15
  30. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow.egg-info/PKG-INFO +11 -1
  31. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow.egg-info/SOURCES.txt +24 -1
  32. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow.egg-info/requires.txt +11 -0
  33. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/pyproject.toml +15 -1
  34. boundflow-0.7.0.dev7/tests/test_approval_audit.py +355 -0
  35. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/tests/test_await_input.py +91 -0
  36. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/tests/test_cooldown_and_resume.py +18 -2
  37. boundflow-0.7.0.dev7/tests/test_custom_runtime_policy.py +92 -0
  38. boundflow-0.7.0.dev7/tests/test_delayed_next.py +216 -0
  39. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/tests/test_get_workflow.py +16 -1
  40. boundflow-0.7.0.dev7/tests/test_governed_model.py +709 -0
  41. boundflow-0.7.0.dev7/tests/test_governed_model_e2e.py +162 -0
  42. boundflow-0.7.0.dev7/tests/test_interim_metrics.py +118 -0
  43. boundflow-0.7.0.dev7/tests/test_interrupted_run_metrics.py +99 -0
  44. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/tests/test_list_workflows.py +2 -0
  45. boundflow-0.7.0.dev7/tests/test_operation_context_ids.py +29 -0
  46. boundflow-0.7.0.dev7/tests/test_orchestrator_call_caps.py +132 -0
  47. boundflow-0.7.0.dev7/tests/test_orphaned_job_recovery.py +77 -0
  48. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/tests/test_resolve_interrupted_workflow.py +21 -0
  49. boundflow-0.7.0.dev7/tests/test_resumable_workflow.py +92 -0
  50. boundflow-0.7.0.dev7/tests/test_run_agent_budget.py +134 -0
  51. boundflow-0.7.0.dev7/tests/test_run_agent_budget_e2e.py +157 -0
  52. boundflow-0.7.0.dev7/tests/test_suspend_parked_gate.py +126 -0
  53. boundflow-0.7.0.dev7/tests/test_suspend_resume.py +552 -0
  54. boundflow-0.7.0.dev7/tests/test_tool_failure_limits.py +149 -0
  55. boundflow-0.7.0.dev7/tests/test_ui_console.py +1090 -0
  56. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/tests/test_workflow_lifecycle_policy.py +11 -2
  57. boundflow-0.5.0/boundflow/langchain_client.py +0 -164
  58. boundflow-0.5.0/boundflow/v1/lifecycle_pb2.py +0 -183
  59. boundflow-0.5.0/boundflow/v1/operation_pb2.py +0 -70
  60. boundflow-0.5.0/boundflow/v1/workflow_pb2.py +0 -49
  61. boundflow-0.5.0/tests/test_approval_audit.py +0 -154
  62. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/LICENSE +0 -0
  63. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/anthropic_client.py +0 -0
  64. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/cli/__main__.py +0 -0
  65. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/cli/commands/__init__.py +0 -0
  66. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/errors.py +0 -0
  67. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/examples/__init__.py +0 -0
  68. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/examples/approval_gate.py +0 -0
  69. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/examples/hello.py +0 -0
  70. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/examples/langchain_adapter.py +0 -0
  71. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/examples/langgraph_workflow.py +0 -0
  72. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/examples/model_switching.py +0 -0
  73. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/examples/runtime_caps.py +0 -0
  74. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/examples/self_healing.py +0 -0
  75. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/trace.py +0 -0
  76. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/v1/__init__.py +0 -0
  77. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/v1/agent_policy_pb2.py +0 -0
  78. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/v1/agent_policy_pb2_grpc.py +0 -0
  79. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/v1/operation_pb2_grpc.py +0 -0
  80. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/v1/policy_pb2.py +0 -0
  81. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/v1/policy_pb2_grpc.py +0 -0
  82. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/v1/pricing_pb2.py +0 -0
  83. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/v1/pricing_pb2_grpc.py +0 -0
  84. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/v1/registration_pb2.py +0 -0
  85. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/v1/registration_pb2_grpc.py +0 -0
  86. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/v1/tenant_group_pb2.py +0 -0
  87. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/v1/tenant_group_pb2_grpc.py +0 -0
  88. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/v1/tenant_pb2.py +0 -0
  89. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/v1/tenant_pb2_grpc.py +0 -0
  90. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/v1/worker_pb2_grpc.py +0 -0
  91. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/v1/workflow_pb2_grpc.py +0 -0
  92. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow.egg-info/dependency_links.txt +0 -0
  93. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow.egg-info/entry_points.txt +0 -0
  94. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow.egg-info/top_level.txt +0 -0
  95. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/setup.cfg +0 -0
  96. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/tests/test_agent_call_timeout.py +0 -0
  97. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/tests/test_agent_lifecycle_policy.py +0 -0
  98. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/tests/test_agent_metrics_latency.py +0 -0
  99. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/tests/test_agent_policy_audit.py +0 -0
  100. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/tests/test_approval_gate.py +0 -0
  101. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/tests/test_capability_routing.py +0 -0
  102. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/tests/test_complete_result.py +0 -0
  103. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/tests/test_delete_tenant.py +0 -0
  104. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/tests/test_delete_workflow.py +0 -0
  105. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/tests/test_get_workflow_metrics.py +0 -0
  106. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/tests/test_invoke_context.py +0 -0
  107. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/tests/test_invoke_mode.py +0 -0
  108. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/tests/test_langchain_client.py +0 -0
  109. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/tests/test_lifecycle_states.py +0 -0
  110. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/tests/test_list_tenants.py +0 -0
  111. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/tests/test_max_tokens_per_call.py +0 -0
  112. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/tests/test_metrics_epoch_isolation.py +0 -0
  113. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/tests/test_mock_llm.py +0 -0
  114. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/tests/test_model_pricing.py +0 -0
  115. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/tests/test_otel_sink.py +0 -0
  116. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/tests/test_pending_approval.py +0 -0
  117. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/tests/test_periodic_workflow.py +0 -0
  118. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/tests/test_policy_audit.py +0 -0
  119. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/tests/test_policy_getters.py +0 -0
  120. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/tests/test_request_info.py +0 -0
  121. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/tests/test_set_workflow_config.py +0 -0
  122. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/tests/test_tenant_purge_reconciler.py +0 -0
  123. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/tests/test_tool_call_limits.py +0 -0
  124. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/tests/test_trace.py +0 -0
  125. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/tests/test_workflow_purge_reconciler.py +0 -0
  126. {boundflow-0.5.0 → boundflow-0.7.0.dev7}/tests/test_workflow_runs.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: boundflow
3
- Version: 0.5.0
3
+ Version: 0.7.0.dev7
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,10 +18,20 @@ 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: langgraph>=0.2; extra == "dev"
22
+ Requires-Dist: deepagents>=0.7; extra == "dev"
23
+ Requires-Dist: starlette>=0.37; extra == "dev"
24
+ Requires-Dist: uvicorn>=0.30; extra == "dev"
25
+ Requires-Dist: python-multipart>=0.0.9; extra == "dev"
26
+ Requires-Dist: httpx; extra == "dev"
21
27
  Requires-Dist: opentelemetry-sdk; extra == "dev"
22
28
  Requires-Dist: opentelemetry-exporter-otlp; extra == "dev"
23
29
  Provides-Extra: langchain
24
30
  Requires-Dist: langchain-core>=0.3; extra == "langchain"
31
+ Provides-Extra: ui
32
+ Requires-Dist: starlette>=0.37; extra == "ui"
33
+ Requires-Dist: uvicorn>=0.30; extra == "ui"
34
+ Requires-Dist: python-multipart>=0.0.9; extra == "ui"
25
35
  Provides-Extra: otel
26
36
  Requires-Dist: opentelemetry-sdk; extra == "otel"
27
37
  Requires-Dist: opentelemetry-exporter-otlp; extra == "otel"
@@ -14,6 +14,7 @@ from .control_plane import (
14
14
  LifecycleState,
15
15
  PendingApproval,
16
16
  PendingInput,
17
+ Suspension,
17
18
  RequestInfo,
18
19
  Run,
19
20
  RunOutcome,
@@ -38,7 +39,11 @@ from .errors import (
38
39
  UnauthenticatedError,
39
40
  UnavailableError,
40
41
  )
41
- from .llm import AgentCallTimeout, MockLlmClient, MockContext, Turn, turn, submit
42
+ from .governed import AgentFinalized, AgentGovernor, GovernedCall
43
+ from .llm import (
44
+ AgentCallTimeout, AgentPolicyLimitExceeded, ToolFailureLimitExceeded,
45
+ MockLlmClient, MockContext, Turn, turn, submit,
46
+ )
42
47
  from .trace import (
43
48
  AgentRunTrace,
44
49
  JsonlFileTraceSink,
@@ -61,6 +66,7 @@ from .policies import (
61
66
  SetModel,
62
67
  SetVersion,
63
68
  ToolCallLimit,
69
+ ToolFailureLimit,
64
70
  WorkflowMetric,
65
71
  WorkflowRule,
66
72
  )
@@ -70,7 +76,9 @@ from .worker import (
70
76
  AwaitApproval,
71
77
  AwaitInput,
72
78
  BoundFlowWorker,
79
+ Budget,
73
80
  Complete,
81
+ ENTRY_OPERATION,
74
82
  InputRequest,
75
83
  Next,
76
84
  OperationContext,
@@ -83,12 +91,16 @@ __all__ = [
83
91
  "AnthropicLlmClient",
84
92
  "ControlPlaneClient", "LifecycleState", "RunStatus", "RunOutcome", "Run", "RequestInfo",
85
93
  "Tenant", "TenantGroup", "Workflow",
86
- "WorkflowConfig", "WorkflowState", "WorkflowInfo", "WorkflowMetrics", "PendingApproval", "PendingInput", "InvokeMode", "ApprovalAuditRecord", "ApprovalDecision", "InputAuditRecord", "InputDecision", "PolicyActionRecord", "WorkflowPolicyAction", "AgentPolicyActionRecord", "AgentCallTimeout", "MockLlmClient", "MockContext", "Turn",
94
+ "WorkflowConfig", "WorkflowState", "WorkflowInfo", "WorkflowMetrics", "PendingApproval", "PendingInput", "InvokeMode", "ApprovalAuditRecord", "ApprovalDecision", "InputAuditRecord", "InputDecision", "PolicyActionRecord", "WorkflowPolicyAction", "AgentPolicyActionRecord", "AgentCallTimeout", "AgentPolicyLimitExceeded", "AgentFinalized", "AgentGovernor", "GovernedCall",
95
+ "MockLlmClient", "MockContext", "Turn",
96
+ "turn", "submit", "AgentMetric", "AgentRule", "Cooldown",
97
+ "Op", "Pause",
98
+ "WorkflowConfig", "WorkflowState", "WorkflowInfo", "WorkflowMetrics", "PendingApproval", "PendingInput", "Suspension", "InvokeMode", "ApprovalAuditRecord", "ApprovalDecision", "InputAuditRecord", "InputDecision", "PolicyActionRecord", "WorkflowPolicyAction", "AgentPolicyActionRecord", "AgentCallTimeout", "AgentPolicyLimitExceeded", "MockLlmClient", "MockContext", "Turn",
87
99
  "turn", "submit", "AgentMetric", "AgentRule", "Cooldown", "Op", "Pause",
88
100
  "RuntimePolicy", "SetMaxCostUsd", "SetMaxLlmCalls", "SetMaxTokensPerCall",
89
- "SetModel", "SetVersion", "ToolCallLimit", "WorkflowMetric", "WorkflowRule",
101
+ "SetModel", "SetVersion", "ToolCallLimit", "ToolFailureLimit", "ToolFailureLimitExceeded", "WorkflowMetric", "WorkflowRule",
90
102
  "AgentDefinition", "ApprovalRequest", "AwaitApproval", "AwaitInput", "InputRequest", "BoundFlowWorker",
91
- "Complete", "Next", "OperationContext", "OperationResult", "Tool", "tool",
103
+ "Budget", "Complete", "ENTRY_OPERATION", "Next", "OperationContext", "OperationResult", "Tool", "tool",
92
104
  "AgentRunTrace", "OperationTrace", "Span", "TraceSink", "LoggingTraceSink",
93
105
  "JsonlFileTraceSink", "OTelTraceSink",
94
106
  "BoundflowError", "NotFoundError", "AlreadyExistsError", "InvalidArgumentError",
@@ -25,7 +25,12 @@ from boundflow.v1 import worker_pb2 as wk_pb
25
25
  from boundflow.v1 import worker_pb2_grpc as wk_grpc
26
26
 
27
27
  # dispatch: given the launched operation proto, produce the result proto.
28
- Dispatch = Callable[[op_pb.AtomicOperation], Awaitable[op_pb.AtomicOperationResult]]
28
+ # The second argument reports metrics mid-operation: an interim AtomicOperationResult
29
+ # carrying nothing but agent_state_updates. See `_run_operation`.
30
+ Dispatch = Callable[
31
+ [op_pb.AtomicOperation, Callable[[op_pb.AtomicOperationResult], Awaitable[None]]],
32
+ Awaitable[op_pb.AtomicOperationResult],
33
+ ]
29
34
 
30
35
 
31
36
  def _strip_scheme(addr: str) -> str:
@@ -175,17 +180,37 @@ class WorkerSession:
175
180
  op_task = asyncio.create_task(self._run_operation(call, op, dispatch))
176
181
  elif which == "cancel":
177
182
  if op_task is not None and command.cancel.operation_id == op_id:
183
+ reason = command.cancel.reason
178
184
  op_task.cancel()
179
185
  try:
180
186
  await op_task
181
187
  except asyncio.CancelledError:
182
- await self._write(call, self._update(op_id, op_pb.OPERATION_STATUS_CANCELLED))
188
+ # Echo the reason back: the server records the run outcome from
189
+ # what actually stopped it, not from what it asked for.
190
+ await self._write(call, self._update(
191
+ op_id, op_pb.OPERATION_STATUS_CANCELLED, cancel_reason=reason))
183
192
  op_task, op_id = None, None
184
193
  await self._write(call, self._ready())
185
194
 
186
195
  async def _run_operation(self, call, op: op_pb.AtomicOperation, dispatch: Dispatch) -> None:
196
+ async def report(interim: op_pb.AtomicOperationResult) -> None:
197
+ """Send metrics before the operation ends.
198
+
199
+ Without this an operation's spend only reaches the server when it finishes,
200
+ so a worker that dies mid-run takes its accounting with it. Sent on the same
201
+ stream, in order, so an interim report can't overtake the final result.
202
+
203
+ No ack, deliberately: a failed write kills the stream and the run is
204
+ interrupted, which bounds the damage to whatever landed after the last
205
+ successful report. Worth revisiting — see the note in `worker.py`.
206
+ """
207
+ await self._write(call, wk_pb.WorkerMessage(
208
+ session_id=self._session_id,
209
+ update=wk_pb.OperationUpdate(operation_id=op.id, result=interim),
210
+ ))
211
+
187
212
  try:
188
- result = await dispatch(op)
213
+ result = await dispatch(op, report)
189
214
  except asyncio.CancelledError:
190
215
  raise # surfaced to the main loop, which sends CANCELLED
191
216
  except Exception as ex: # noqa: BLE001 — report a handler failure
@@ -209,11 +234,14 @@ class WorkerSession:
209
234
  ready=wk_pb.ReadyForWork(capabilities=self._capabilities),
210
235
  )
211
236
 
212
- def _update(self, operation_id: str, status, message: str = "") -> wk_pb.WorkerMessage:
237
+ def _update(self, operation_id: str, status, message: str = "", cancel_reason=None) -> wk_pb.WorkerMessage:
238
+ result = op_pb.AtomicOperationResult(status=status, message=message)
239
+ if cancel_reason is not None:
240
+ result.cancel_reason = cancel_reason
213
241
  return wk_pb.WorkerMessage(
214
242
  session_id=self._session_id,
215
243
  update=wk_pb.OperationUpdate(
216
244
  operation_id=operation_id,
217
- result=op_pb.AtomicOperationResult(status=status, message=message),
245
+ result=result,
218
246
  ),
219
247
  )
@@ -2,8 +2,8 @@
2
2
 
3
3
  import typer
4
4
 
5
- from boundflow.cli._client import configure
6
- from boundflow.cli._output import set_json
5
+ from boundflow.cli._client import configure, resolved
6
+ from boundflow.cli.output import error, set_json
7
7
  from boundflow.cli.commands import audit, policies, pricing, tenants, workflows
8
8
 
9
9
  app = typer.Typer(
@@ -38,5 +38,20 @@ def root(
38
38
  configure(server, api_key)
39
39
 
40
40
 
41
+ @app.command("ui")
42
+ def ui(
43
+ port: int = typer.Option(8787, "--port", help="Port to serve on (localhost only)"),
44
+ no_browser: bool = typer.Option(False, "--no-browser", help="Don't open a browser"),
45
+ ):
46
+ """Serve the local operator console: the fleet, its gates, and holds."""
47
+ from boundflow.ui import serve
48
+
49
+ server, api_key = resolved()
50
+ if not api_key:
51
+ error("no API key. Set BOUNDFLOW_API_KEY or pass --api-key.")
52
+ raise typer.Exit(1)
53
+ serve(server, api_key, port=port, open_browser=not no_browser)
54
+
55
+
41
56
  def main() -> None:
42
57
  app()
@@ -5,7 +5,7 @@ import os
5
5
 
6
6
  import typer
7
7
 
8
- from boundflow.cli._output import error as report_error
8
+ from boundflow.cli.output import error as report_error
9
9
  from boundflow.control_plane import ControlPlaneClient, DEFAULT_SERVER_ADDRESS
10
10
 
11
11
  _server: str = DEFAULT_SERVER_ADDRESS
@@ -41,3 +41,9 @@ def cp_call(fn):
41
41
  except Exception as exc:
42
42
  report_error(str(exc))
43
43
  raise typer.Exit(1)
44
+
45
+
46
+ def resolved() -> tuple[str, str]:
47
+ """The server address and API key the root callback settled on, for commands that
48
+ manage their own connection rather than going through cp_call."""
49
+ return _server, _api_key
@@ -1,4 +1,4 @@
1
- """boundflow audit — approval and policy audit log commands."""
1
+ """boundflow audit — approval, input and policy audit log commands."""
2
2
 
3
3
  from dataclasses import asdict
4
4
  from typing import Optional
@@ -6,9 +6,9 @@ from typing import Optional
6
6
  import typer
7
7
 
8
8
  from boundflow.cli._client import cp_call
9
- from boundflow.cli._output import output
9
+ from boundflow.cli.output import output
10
10
 
11
- app = typer.Typer(help="View approval and policy audit records.")
11
+ app = typer.Typer(help="View approval, input and policy audit records.")
12
12
 
13
13
 
14
14
  def _flatten(record) -> dict:
@@ -34,6 +34,15 @@ def approvals(
34
34
  output([_flatten(r) for r in results])
35
35
 
36
36
 
37
+ @app.command("inputs")
38
+ def inputs(
39
+ workflow_id: str = typer.Argument(..., help="Workflow ID"),
40
+ ):
41
+ """List input-gate decisions for a workflow."""
42
+ results = cp_call(lambda cp: cp.get_input_audit(workflow_id))
43
+ output([_flatten(r) for r in results])
44
+
45
+
37
46
  @app.command("workflow")
38
47
  def workflow_policy(
39
48
  workflow_id: str = typer.Argument(..., help="Workflow ID"),
@@ -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 error, output, success
10
+ from boundflow.cli.output import error, 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.")
@@ -38,6 +38,22 @@ def _load_json(file: Optional[Path], inline: Optional[str]) -> list:
38
38
  raise typer.Exit(1)
39
39
 
40
40
 
41
+ def _parse_custom(raw: Optional[str]) -> dict:
42
+ """--custom takes a JSON object. Anything else is a mistake worth stopping for: these
43
+ caps are never validated downstream, so a malformed one would just quietly not exist."""
44
+ if not raw:
45
+ return {}
46
+ try:
47
+ parsed = json.loads(raw)
48
+ except json.JSONDecodeError as e:
49
+ error(f"--custom must be valid JSON: {e}")
50
+ raise typer.Exit(1)
51
+ if not isinstance(parsed, dict):
52
+ error("--custom must be a JSON object.")
53
+ raise typer.Exit(1)
54
+ return parsed
55
+
56
+
41
57
  @app.command("runtime")
42
58
  def runtime_set(
43
59
  workflow_id: str = typer.Argument(..., help="Workflow ID"),
@@ -47,6 +63,7 @@ def runtime_set(
47
63
  max_cost_usd: Optional[float] = typer.Option(None, "--max-cost-usd", help="Max cost in USD per run"),
48
64
  max_tokens_per_call: Optional[int] = typer.Option(None, "--max-tokens-per-call", help="Max tokens per LLM call"),
49
65
  tool_limit: List[str] = typer.Option([], "--tool-limit", help="Per-tool call limit as TOOL:MAX (repeatable)"),
66
+ custom: Optional[str] = typer.Option(None, "--custom", help="JSON object of caps BoundFlow stores but does not enforce"),
50
67
  file: Optional[Path] = typer.Option(None, "--file", help="JSON file with a RuntimePolicy object (overrides all flags)"),
51
68
  ):
52
69
  """Set the runtime (hard cap) policy for an agent."""
@@ -60,11 +77,32 @@ def runtime_set(
60
77
  max_cost_usd=max_cost_usd or 0.0,
61
78
  max_tokens_per_call=max_tokens_per_call or 0,
62
79
  tool_call_limits=limits,
80
+ custom=_parse_custom(custom),
63
81
  )
64
82
  cp_call(lambda cp: cp.set_agent_runtime_policy(workflow_id, agent_name, policy))
65
83
  success(f"Runtime policy set on {workflow_id} / {agent_name}.")
66
84
 
67
85
 
86
+ @app.command("agents")
87
+ def list_agents(
88
+ workflow_id: str = typer.Argument(..., help="Workflow ID"),
89
+ ):
90
+ """List a workflow's agents and their armed policies.
91
+
92
+ Every other agent command needs a name; this is where the names come from.
93
+ """
94
+ agents = cp_call(lambda cp: cp.list_agents(workflow_id))
95
+ output([
96
+ {
97
+ "agent_name": a.agent_name,
98
+ "runtime_policy": a.runtime_policy or {},
99
+ "lifecycle_policy": a.lifecycle_policy or {},
100
+ "updated_at": a.updated_at,
101
+ }
102
+ for a in agents
103
+ ])
104
+
105
+
68
106
  @app.command("get-runtime")
69
107
  def runtime_get(
70
108
  workflow_id: str = typer.Argument(..., help="Workflow ID"),
@@ -3,7 +3,7 @@
3
3
  import typer
4
4
 
5
5
  from boundflow.cli._client import cp_call
6
- from boundflow.cli._output import output, success
6
+ from boundflow.cli.output import output, success
7
7
 
8
8
  app = typer.Typer(help="Manage model pricing rates.")
9
9
 
@@ -3,7 +3,7 @@
3
3
  import typer
4
4
 
5
5
  from boundflow.cli._client import cp_call
6
- from boundflow.cli._output import output, success
6
+ from boundflow.cli.output import output, success
7
7
 
8
8
  app = typer.Typer(help="Manage tenant groups and tenants.")
9
9
 
@@ -1,12 +1,12 @@
1
1
  """boundflow workflow — workflow lifecycle commands."""
2
2
 
3
3
  import json
4
- from typing import Optional
4
+ from typing import List, Optional
5
5
 
6
6
  import typer
7
7
 
8
8
  from boundflow.cli._client import cp_call
9
- from boundflow.cli._output import error, output, success
9
+ from boundflow.cli.output import error, output, success
10
10
  from boundflow.control_plane import InvokeMode, WorkflowConfig
11
11
 
12
12
  app = typer.Typer(help="Manage workflows.")
@@ -129,6 +129,45 @@ def request(
129
129
  output(result)
130
130
 
131
131
 
132
+ @app.command("suspend")
133
+ def suspend(
134
+ workflow_id: str = typer.Argument(..., help="Workflow ID"),
135
+ reason: str = typer.Option("", "--reason", help="Why it was held; shown on the workflow"),
136
+ stop_current_run: bool = typer.Option(False, "--stop-current-run", help="Stop the run in flight instead of letting it finish"),
137
+ suspension_id: str = typer.Option("", "--suspension-id", help="Retarget this hold instead of starting a new one"),
138
+ ):
139
+ """Hold a workflow. Prints the suspension_id that 'resume' requires."""
140
+ sid = cp_call(lambda cp: cp.suspend_workflow(
141
+ workflow_id, reason=reason, stop_current_run=stop_current_run,
142
+ suspension_id=suspension_id))
143
+ output({"suspension_id": sid})
144
+
145
+
146
+ @app.command("resume")
147
+ def resume(
148
+ workflow_id: str = typer.Argument(..., help="Workflow ID"),
149
+ suspension_id: str = typer.Argument(..., help="From 'suspend', or workflow.suspension.suspension_id"),
150
+ ):
151
+ """Release a hold. Only valid once the suspension has finished draining."""
152
+ cp_call(lambda cp: cp.resume_workflow(workflow_id, suspension_id))
153
+ success(f"Workflow {workflow_id} resumed.")
154
+
155
+
156
+ @app.command("abandon-queued")
157
+ def abandon_queued(
158
+ workflow_id: str = typer.Argument(..., help="Workflow ID"),
159
+ request_id: List[str] = typer.Option([], "--request-id", help="A queued run to abandon (repeatable)"),
160
+ all: bool = typer.Option(False, "--all", help="Abandon every queued run"),
161
+ ):
162
+ """Drop queued runs. Irreversible; a run already in progress is left alone."""
163
+ if all == bool(request_id):
164
+ error("provide --request-id (repeatable) or --all, not both.")
165
+ raise typer.Exit(1)
166
+ abandoned = cp_call(lambda cp: cp.abandon_queued_requests(
167
+ workflow_id, request_ids=list(request_id), all=all))
168
+ output({"abandoned": abandoned})
169
+
170
+
132
171
  @app.command("resolve")
133
172
  def resolve(
134
173
  workflow_id: str = typer.Argument(..., help="Workflow ID"),
@@ -144,9 +183,10 @@ def approve(
144
183
  workflow_id: str = typer.Argument(..., help="Workflow ID"),
145
184
  approval_id: str = typer.Argument(..., help="Approval ID (from the approval request)"),
146
185
  actor: str = typer.Option("", "--actor", help="Approver identity (e.g. email or user ID)"),
186
+ reason: str = typer.Option("", "--reason", help="Why it was approved; recorded on the audit event"),
147
187
  ):
148
188
  """Approve a pending workflow approval gate."""
149
- cp_call(lambda cp: cp.approve_workflow(workflow_id, approval_id, actor=actor))
189
+ cp_call(lambda cp: cp.approve_workflow(workflow_id, approval_id, actor=actor, reason=reason))
150
190
  success(f"Approval {approval_id} approved.")
151
191
 
152
192
 
@@ -155,9 +195,10 @@ def reject(
155
195
  workflow_id: str = typer.Argument(..., help="Workflow ID"),
156
196
  approval_id: str = typer.Argument(..., help="Approval ID (from the approval request)"),
157
197
  actor: str = typer.Option("", "--actor", help="Rejecter identity (e.g. email or user ID)"),
198
+ reason: str = typer.Option("", "--reason", help="Why it was rejected; recorded on the audit event"),
158
199
  ):
159
200
  """Reject a pending workflow approval gate."""
160
- cp_call(lambda cp: cp.reject_workflow(workflow_id, approval_id, actor=actor))
201
+ cp_call(lambda cp: cp.reject_workflow(workflow_id, approval_id, actor=actor, reason=reason))
161
202
  success(f"Approval {approval_id} rejected.")
162
203
 
163
204
 
@@ -1,4 +1,12 @@
1
- """Rich table rendering and --json output helpers."""
1
+ """Rendering for CLIs built on BoundFlow — tables, or JSON under --json.
2
+
3
+ Public so tools wrapping the control plane render the same way. `output()` derives its
4
+ columns from the data, so a field added to a BoundFlow object appears without anyone
5
+ updating a list of things to print — which is the point: a renderer that picks fields by
6
+ hand silently drops the ones nobody remembered.
7
+
8
+ `set_json()` writes a module global, so a process running two CLIs shares the flag.
9
+ """
2
10
 
3
11
  import json
4
12
  from dataclasses import asdict, is_dataclass