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.
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/PKG-INFO +11 -1
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/__init__.py +16 -4
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/_transport.py +33 -5
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/cli/__init__.py +17 -2
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/cli/_client.py +7 -1
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/cli/commands/audit.py +12 -3
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/cli/commands/policies.py +39 -1
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/cli/commands/pricing.py +1 -1
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/cli/commands/tenants.py +1 -1
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/cli/commands/workflows.py +45 -4
- boundflow-0.5.0/boundflow/cli/_output.py → boundflow-0.7.0.dev7/boundflow/cli/output.py +9 -1
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/control_plane.py +176 -14
- boundflow-0.7.0.dev7/boundflow/examples/governed_langgraph.py +120 -0
- boundflow-0.7.0.dev7/boundflow/governed.py +601 -0
- boundflow-0.7.0.dev7/boundflow/langchain_client.py +529 -0
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/lifecycle.py +40 -13
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/llm.py +83 -77
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/policies.py +28 -3
- boundflow-0.7.0.dev7/boundflow/ui/__init__.py +22 -0
- boundflow-0.7.0.dev7/boundflow/ui/labels.py +60 -0
- boundflow-0.7.0.dev7/boundflow/ui/render.py +328 -0
- boundflow-0.7.0.dev7/boundflow/ui/server.py +398 -0
- boundflow-0.7.0.dev7/boundflow/ui/views.py +690 -0
- boundflow-0.7.0.dev7/boundflow/v1/lifecycle_pb2.py +201 -0
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/v1/lifecycle_pb2_grpc.py +179 -0
- boundflow-0.7.0.dev7/boundflow/v1/operation_pb2.py +72 -0
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/v1/worker_pb2.py +4 -4
- boundflow-0.7.0.dev7/boundflow/v1/workflow_pb2.py +51 -0
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/worker.py +342 -15
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow.egg-info/PKG-INFO +11 -1
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow.egg-info/SOURCES.txt +24 -1
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow.egg-info/requires.txt +11 -0
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/pyproject.toml +15 -1
- boundflow-0.7.0.dev7/tests/test_approval_audit.py +355 -0
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/tests/test_await_input.py +91 -0
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/tests/test_cooldown_and_resume.py +18 -2
- boundflow-0.7.0.dev7/tests/test_custom_runtime_policy.py +92 -0
- boundflow-0.7.0.dev7/tests/test_delayed_next.py +216 -0
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/tests/test_get_workflow.py +16 -1
- boundflow-0.7.0.dev7/tests/test_governed_model.py +709 -0
- boundflow-0.7.0.dev7/tests/test_governed_model_e2e.py +162 -0
- boundflow-0.7.0.dev7/tests/test_interim_metrics.py +118 -0
- boundflow-0.7.0.dev7/tests/test_interrupted_run_metrics.py +99 -0
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/tests/test_list_workflows.py +2 -0
- boundflow-0.7.0.dev7/tests/test_operation_context_ids.py +29 -0
- boundflow-0.7.0.dev7/tests/test_orchestrator_call_caps.py +132 -0
- boundflow-0.7.0.dev7/tests/test_orphaned_job_recovery.py +77 -0
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/tests/test_resolve_interrupted_workflow.py +21 -0
- boundflow-0.7.0.dev7/tests/test_resumable_workflow.py +92 -0
- boundflow-0.7.0.dev7/tests/test_run_agent_budget.py +134 -0
- boundflow-0.7.0.dev7/tests/test_run_agent_budget_e2e.py +157 -0
- boundflow-0.7.0.dev7/tests/test_suspend_parked_gate.py +126 -0
- boundflow-0.7.0.dev7/tests/test_suspend_resume.py +552 -0
- boundflow-0.7.0.dev7/tests/test_tool_failure_limits.py +149 -0
- boundflow-0.7.0.dev7/tests/test_ui_console.py +1090 -0
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/tests/test_workflow_lifecycle_policy.py +11 -2
- boundflow-0.5.0/boundflow/langchain_client.py +0 -164
- boundflow-0.5.0/boundflow/v1/lifecycle_pb2.py +0 -183
- boundflow-0.5.0/boundflow/v1/operation_pb2.py +0 -70
- boundflow-0.5.0/boundflow/v1/workflow_pb2.py +0 -49
- boundflow-0.5.0/tests/test_approval_audit.py +0 -154
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/LICENSE +0 -0
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/anthropic_client.py +0 -0
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/cli/__main__.py +0 -0
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/cli/commands/__init__.py +0 -0
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/errors.py +0 -0
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/examples/__init__.py +0 -0
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/examples/approval_gate.py +0 -0
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/examples/hello.py +0 -0
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/examples/langchain_adapter.py +0 -0
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/examples/langgraph_workflow.py +0 -0
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/examples/model_switching.py +0 -0
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/examples/runtime_caps.py +0 -0
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/examples/self_healing.py +0 -0
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/trace.py +0 -0
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/v1/__init__.py +0 -0
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/v1/agent_policy_pb2.py +0 -0
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/v1/agent_policy_pb2_grpc.py +0 -0
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/v1/operation_pb2_grpc.py +0 -0
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/v1/policy_pb2.py +0 -0
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/v1/policy_pb2_grpc.py +0 -0
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/v1/pricing_pb2.py +0 -0
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/v1/pricing_pb2_grpc.py +0 -0
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/v1/registration_pb2.py +0 -0
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/v1/registration_pb2_grpc.py +0 -0
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/v1/tenant_group_pb2.py +0 -0
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/v1/tenant_group_pb2_grpc.py +0 -0
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/v1/tenant_pb2.py +0 -0
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/v1/tenant_pb2_grpc.py +0 -0
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/v1/worker_pb2_grpc.py +0 -0
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow/v1/workflow_pb2_grpc.py +0 -0
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow.egg-info/dependency_links.txt +0 -0
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow.egg-info/entry_points.txt +0 -0
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/boundflow.egg-info/top_level.txt +0 -0
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/setup.cfg +0 -0
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/tests/test_agent_call_timeout.py +0 -0
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/tests/test_agent_lifecycle_policy.py +0 -0
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/tests/test_agent_metrics_latency.py +0 -0
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/tests/test_agent_policy_audit.py +0 -0
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/tests/test_approval_gate.py +0 -0
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/tests/test_capability_routing.py +0 -0
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/tests/test_complete_result.py +0 -0
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/tests/test_delete_tenant.py +0 -0
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/tests/test_delete_workflow.py +0 -0
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/tests/test_get_workflow_metrics.py +0 -0
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/tests/test_invoke_context.py +0 -0
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/tests/test_invoke_mode.py +0 -0
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/tests/test_langchain_client.py +0 -0
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/tests/test_lifecycle_states.py +0 -0
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/tests/test_list_tenants.py +0 -0
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/tests/test_max_tokens_per_call.py +0 -0
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/tests/test_metrics_epoch_isolation.py +0 -0
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/tests/test_mock_llm.py +0 -0
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/tests/test_model_pricing.py +0 -0
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/tests/test_otel_sink.py +0 -0
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/tests/test_pending_approval.py +0 -0
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/tests/test_periodic_workflow.py +0 -0
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/tests/test_policy_audit.py +0 -0
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/tests/test_policy_getters.py +0 -0
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/tests/test_request_info.py +0 -0
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/tests/test_set_workflow_config.py +0 -0
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/tests/test_tenant_purge_reconciler.py +0 -0
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/tests/test_tool_call_limits.py +0 -0
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/tests/test_trace.py +0 -0
- {boundflow-0.5.0 → boundflow-0.7.0.dev7}/tests/test_workflow_purge_reconciler.py +0 -0
- {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.
|
|
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 .
|
|
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", "
|
|
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
|
-
|
|
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
|
-
|
|
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=
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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"),
|
|
@@ -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.
|
|
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
|
-
"""
|
|
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
|