boundflow 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- boundflow-0.1.0/LICENSE +21 -0
- boundflow-0.1.0/PKG-INFO +22 -0
- boundflow-0.1.0/boundflow/__init__.py +88 -0
- boundflow-0.1.0/boundflow/_transport.py +213 -0
- boundflow-0.1.0/boundflow/anthropic_client.py +79 -0
- boundflow-0.1.0/boundflow/cli/__init__.py +42 -0
- boundflow-0.1.0/boundflow/cli/__main__.py +5 -0
- boundflow-0.1.0/boundflow/cli/_client.py +42 -0
- boundflow-0.1.0/boundflow/cli/_output.py +90 -0
- boundflow-0.1.0/boundflow/cli/commands/__init__.py +0 -0
- boundflow-0.1.0/boundflow/cli/commands/audit.py +62 -0
- boundflow-0.1.0/boundflow/cli/commands/policies.py +100 -0
- boundflow-0.1.0/boundflow/cli/commands/pricing.py +30 -0
- boundflow-0.1.0/boundflow/cli/commands/tenants.py +63 -0
- boundflow-0.1.0/boundflow/cli/commands/workflows.py +133 -0
- boundflow-0.1.0/boundflow/control_plane.py +680 -0
- boundflow-0.1.0/boundflow/errors.py +79 -0
- boundflow-0.1.0/boundflow/examples/__init__.py +1 -0
- boundflow-0.1.0/boundflow/examples/approval_gate.py +87 -0
- boundflow-0.1.0/boundflow/examples/hello.py +62 -0
- boundflow-0.1.0/boundflow/examples/model_switching.py +69 -0
- boundflow-0.1.0/boundflow/examples/runtime_caps.py +78 -0
- boundflow-0.1.0/boundflow/examples/self_healing.py +78 -0
- boundflow-0.1.0/boundflow/lifecycle.py +138 -0
- boundflow-0.1.0/boundflow/llm.py +415 -0
- boundflow-0.1.0/boundflow/policies.py +128 -0
- boundflow-0.1.0/boundflow/trace.py +272 -0
- boundflow-0.1.0/boundflow/v1/__init__.py +1 -0
- boundflow-0.1.0/boundflow/v1/agent_policy_pb2.py +53 -0
- boundflow-0.1.0/boundflow/v1/agent_policy_pb2_grpc.py +24 -0
- boundflow-0.1.0/boundflow/v1/lifecycle_pb2.py +147 -0
- boundflow-0.1.0/boundflow/v1/lifecycle_pb2_grpc.py +916 -0
- boundflow-0.1.0/boundflow/v1/operation_pb2.py +68 -0
- boundflow-0.1.0/boundflow/v1/operation_pb2_grpc.py +24 -0
- boundflow-0.1.0/boundflow/v1/policy_pb2.py +50 -0
- boundflow-0.1.0/boundflow/v1/policy_pb2_grpc.py +24 -0
- boundflow-0.1.0/boundflow/v1/pricing_pb2.py +37 -0
- boundflow-0.1.0/boundflow/v1/pricing_pb2_grpc.py +24 -0
- boundflow-0.1.0/boundflow/v1/registration_pb2.py +76 -0
- boundflow-0.1.0/boundflow/v1/registration_pb2_grpc.py +452 -0
- boundflow-0.1.0/boundflow/v1/tenant_group_pb2.py +40 -0
- boundflow-0.1.0/boundflow/v1/tenant_group_pb2_grpc.py +24 -0
- boundflow-0.1.0/boundflow/v1/tenant_pb2.py +39 -0
- boundflow-0.1.0/boundflow/v1/tenant_pb2_grpc.py +24 -0
- boundflow-0.1.0/boundflow/v1/worker_pb2.py +52 -0
- boundflow-0.1.0/boundflow/v1/worker_pb2_grpc.py +112 -0
- boundflow-0.1.0/boundflow/v1/workflow_pb2.py +42 -0
- boundflow-0.1.0/boundflow/v1/workflow_pb2_grpc.py +24 -0
- boundflow-0.1.0/boundflow/worker.py +415 -0
- boundflow-0.1.0/boundflow.egg-info/PKG-INFO +22 -0
- boundflow-0.1.0/boundflow.egg-info/SOURCES.txt +75 -0
- boundflow-0.1.0/boundflow.egg-info/dependency_links.txt +1 -0
- boundflow-0.1.0/boundflow.egg-info/entry_points.txt +2 -0
- boundflow-0.1.0/boundflow.egg-info/requires.txt +15 -0
- boundflow-0.1.0/boundflow.egg-info/top_level.txt +1 -0
- boundflow-0.1.0/pyproject.toml +49 -0
- boundflow-0.1.0/setup.cfg +4 -0
- boundflow-0.1.0/tests/test_agent_lifecycle_policy.py +85 -0
- boundflow-0.1.0/tests/test_agent_policy_audit.py +113 -0
- boundflow-0.1.0/tests/test_approval_audit.py +154 -0
- boundflow-0.1.0/tests/test_approval_gate.py +158 -0
- boundflow-0.1.0/tests/test_capability_routing.py +62 -0
- boundflow-0.1.0/tests/test_cooldown_and_resume.py +127 -0
- boundflow-0.1.0/tests/test_lifecycle_states.py +120 -0
- boundflow-0.1.0/tests/test_list_tenants.py +23 -0
- boundflow-0.1.0/tests/test_list_workflows.py +62 -0
- boundflow-0.1.0/tests/test_max_tokens_per_call.py +84 -0
- boundflow-0.1.0/tests/test_mock_llm.py +74 -0
- boundflow-0.1.0/tests/test_model_pricing.py +232 -0
- boundflow-0.1.0/tests/test_otel_sink.py +81 -0
- boundflow-0.1.0/tests/test_periodic_workflow.py +133 -0
- boundflow-0.1.0/tests/test_policy_audit.py +104 -0
- boundflow-0.1.0/tests/test_resolve_interrupted_workflow.py +79 -0
- boundflow-0.1.0/tests/test_tool_call_limits.py +68 -0
- boundflow-0.1.0/tests/test_trace.py +267 -0
- boundflow-0.1.0/tests/test_workflow_lifecycle_policy.py +375 -0
- boundflow-0.1.0/tests/test_workflow_runs.py +61 -0
boundflow-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Arjun Lama
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
boundflow-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: boundflow
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: BoundFlow Python SDK — build governed fleets of agents against a self-hostable control plane.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Keywords: agents,llm,orchestration,control-plane,governance
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: grpcio>=1.81.0
|
|
10
|
+
Requires-Dist: protobuf<7,>=6.30
|
|
11
|
+
Requires-Dist: pydantic>=2.0
|
|
12
|
+
Requires-Dist: anthropic>=0.40
|
|
13
|
+
Requires-Dist: typer>=0.12
|
|
14
|
+
Requires-Dist: rich>=13
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: grpcio-tools>=1.81.0; extra == "dev"
|
|
17
|
+
Requires-Dist: pytest; extra == "dev"
|
|
18
|
+
Requires-Dist: pytest-asyncio; extra == "dev"
|
|
19
|
+
Provides-Extra: otel
|
|
20
|
+
Requires-Dist: opentelemetry-sdk; extra == "otel"
|
|
21
|
+
Requires-Dist: opentelemetry-exporter-otlp; extra == "otel"
|
|
22
|
+
Dynamic: license-file
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""BoundFlow Python SDK — governance for agentic workflows."""
|
|
2
|
+
|
|
3
|
+
from .anthropic_client import AnthropicLlmClient
|
|
4
|
+
from .control_plane import (
|
|
5
|
+
ControlPlaneClient,
|
|
6
|
+
ApprovalAuditRecord,
|
|
7
|
+
ApprovalDecision,
|
|
8
|
+
PolicyActionRecord,
|
|
9
|
+
WorkflowPolicyAction,
|
|
10
|
+
AgentPolicyActionRecord,
|
|
11
|
+
LifecycleState,
|
|
12
|
+
RequestInfo,
|
|
13
|
+
Run,
|
|
14
|
+
RunOutcome,
|
|
15
|
+
RunStatus,
|
|
16
|
+
Tenant,
|
|
17
|
+
TenantGroup,
|
|
18
|
+
Workflow,
|
|
19
|
+
WorkflowConfig,
|
|
20
|
+
WorkflowState,
|
|
21
|
+
WorkflowSummary,
|
|
22
|
+
)
|
|
23
|
+
from .errors import (
|
|
24
|
+
AlreadyExistsError,
|
|
25
|
+
BoundflowError,
|
|
26
|
+
DeadlineExceededError,
|
|
27
|
+
FailedPreconditionError,
|
|
28
|
+
InvalidArgumentError,
|
|
29
|
+
NotFoundError,
|
|
30
|
+
PermissionDeniedError,
|
|
31
|
+
UnauthenticatedError,
|
|
32
|
+
UnavailableError,
|
|
33
|
+
)
|
|
34
|
+
from .llm import MockLlmClient, MockContext, Turn, turn, submit
|
|
35
|
+
from .trace import (
|
|
36
|
+
AgentRunTrace,
|
|
37
|
+
JsonlFileTraceSink,
|
|
38
|
+
LoggingTraceSink,
|
|
39
|
+
OperationTrace,
|
|
40
|
+
OTelTraceSink,
|
|
41
|
+
Span,
|
|
42
|
+
TraceSink,
|
|
43
|
+
)
|
|
44
|
+
from .policies import (
|
|
45
|
+
AgentMetric,
|
|
46
|
+
AgentRule,
|
|
47
|
+
Cooldown,
|
|
48
|
+
Op,
|
|
49
|
+
Pause,
|
|
50
|
+
RuntimePolicy,
|
|
51
|
+
SetMaxCostUsd,
|
|
52
|
+
SetMaxLlmCalls,
|
|
53
|
+
SetMaxTokensPerCall,
|
|
54
|
+
SetModel,
|
|
55
|
+
SetVersion,
|
|
56
|
+
ToolCallLimit,
|
|
57
|
+
WorkflowMetric,
|
|
58
|
+
WorkflowRule,
|
|
59
|
+
)
|
|
60
|
+
from .worker import (
|
|
61
|
+
AgentDefinition,
|
|
62
|
+
ApprovalRequest,
|
|
63
|
+
AwaitApproval,
|
|
64
|
+
BoundFlowWorker,
|
|
65
|
+
Complete,
|
|
66
|
+
Next,
|
|
67
|
+
OperationContext,
|
|
68
|
+
OperationResult,
|
|
69
|
+
Tool,
|
|
70
|
+
tool,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
__all__ = [
|
|
74
|
+
"AnthropicLlmClient",
|
|
75
|
+
"ControlPlaneClient", "LifecycleState", "RunStatus", "RunOutcome", "Run", "RequestInfo",
|
|
76
|
+
"Tenant", "TenantGroup", "Workflow",
|
|
77
|
+
"WorkflowConfig", "WorkflowState", "WorkflowSummary", "ApprovalAuditRecord", "ApprovalDecision", "PolicyActionRecord", "WorkflowPolicyAction", "AgentPolicyActionRecord", "MockLlmClient", "MockContext", "Turn",
|
|
78
|
+
"turn", "submit", "AgentMetric", "AgentRule", "Cooldown", "Op", "Pause",
|
|
79
|
+
"RuntimePolicy", "SetMaxCostUsd", "SetMaxLlmCalls", "SetMaxTokensPerCall",
|
|
80
|
+
"SetModel", "SetVersion", "ToolCallLimit", "WorkflowMetric", "WorkflowRule",
|
|
81
|
+
"AgentDefinition", "ApprovalRequest", "AwaitApproval", "BoundFlowWorker",
|
|
82
|
+
"Complete", "Next", "OperationContext", "OperationResult", "Tool", "tool",
|
|
83
|
+
"AgentRunTrace", "OperationTrace", "Span", "TraceSink", "LoggingTraceSink",
|
|
84
|
+
"JsonlFileTraceSink", "OTelTraceSink",
|
|
85
|
+
"BoundflowError", "NotFoundError", "AlreadyExistsError", "InvalidArgumentError",
|
|
86
|
+
"FailedPreconditionError", "PermissionDeniedError", "UnauthenticatedError",
|
|
87
|
+
"UnavailableError", "DeadlineExceededError",
|
|
88
|
+
]
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""gRPC transport — worker bidi stream + proto⇄domain conversion.
|
|
2
|
+
|
|
3
|
+
Port of BoundFlow.SDK WorkerClient (the stream loop) and the MapToProto helpers
|
|
4
|
+
in BoundFlowWorker, on top of grpc.aio. The server drives the session with
|
|
5
|
+
Launch/Cancel commands; the worker acks IN_PROGRESS, runs the operation off the
|
|
6
|
+
receive loop, then reports the result and re-arms with ReadyForWork.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import logging
|
|
13
|
+
import uuid
|
|
14
|
+
from typing import Awaitable, Callable
|
|
15
|
+
|
|
16
|
+
import grpc
|
|
17
|
+
|
|
18
|
+
log = logging.getLogger("boundflow.worker")
|
|
19
|
+
from google.protobuf import json_format
|
|
20
|
+
from google.protobuf.struct_pb2 import Struct
|
|
21
|
+
|
|
22
|
+
from boundflow.v1 import agent_policy_pb2 as ap_pb
|
|
23
|
+
from boundflow.v1 import operation_pb2 as op_pb
|
|
24
|
+
from boundflow.v1 import worker_pb2 as wk_pb
|
|
25
|
+
from boundflow.v1 import worker_pb2_grpc as wk_grpc
|
|
26
|
+
|
|
27
|
+
# dispatch: given the launched operation proto, produce the result proto.
|
|
28
|
+
Dispatch = Callable[[op_pb.AtomicOperation], Awaitable[op_pb.AtomicOperationResult]]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _strip_scheme(addr: str) -> str:
|
|
32
|
+
return addr.split("://", 1)[1] if "://" in addr else addr
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def context_to_dict(op: op_pb.AtomicOperation) -> dict:
|
|
36
|
+
"""AtomicOperation.context (Struct) → plain dict, camelCase top-level keys
|
|
37
|
+
(matching the .NET JsonFormatter), opaque policy contents preserved."""
|
|
38
|
+
if not op.HasField("context"):
|
|
39
|
+
return {}
|
|
40
|
+
return json_format.MessageToDict(op.context)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def dict_to_struct(d: dict) -> Struct:
|
|
44
|
+
s = Struct()
|
|
45
|
+
s.update(d or {})
|
|
46
|
+
return s
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def new_approval_id() -> str:
|
|
50
|
+
return str(uuid.uuid4())
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def metrics_to_proto(snapshot: dict) -> op_pb.AgentInvocationMetrics:
|
|
54
|
+
m = op_pb.AgentInvocationMetrics(
|
|
55
|
+
cost_usd=snapshot.get("cost_usd", 0.0),
|
|
56
|
+
llm_calls=snapshot.get("llm_calls", 0),
|
|
57
|
+
tokens_used=snapshot.get("tokens_used", 0),
|
|
58
|
+
ran_at=snapshot.get("ran_at", 0),
|
|
59
|
+
)
|
|
60
|
+
for tool, count in (snapshot.get("calls_per_tool") or {}).items():
|
|
61
|
+
m.calls_per_tool[tool] = count
|
|
62
|
+
for tool, count in (snapshot.get("tool_failure_counts") or {}).items():
|
|
63
|
+
m.tool_failure_counts[tool] = count
|
|
64
|
+
return m
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
_AGENT_METRIC_PB = {
|
|
68
|
+
"tokens_used": ap_pb.AGENT_METRIC_TOKENS_USED,
|
|
69
|
+
"cost_usd": ap_pb.AGENT_METRIC_COST_USD,
|
|
70
|
+
"llm_calls": ap_pb.AGENT_METRIC_LLM_CALLS,
|
|
71
|
+
"calls_per_tool": ap_pb.AGENT_METRIC_CALLS_PER_TOOL,
|
|
72
|
+
}
|
|
73
|
+
_AGENT_OP_PB = {
|
|
74
|
+
"less_than": ap_pb.AGENT_OP_LT,
|
|
75
|
+
"less_than_or_equal": ap_pb.AGENT_OP_LTE,
|
|
76
|
+
"greater_than": ap_pb.AGENT_OP_GT,
|
|
77
|
+
"greater_than_or_equal": ap_pb.AGENT_OP_GTE,
|
|
78
|
+
"equal": ap_pb.AGENT_OP_EQ,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _enum_value(v) -> str:
|
|
83
|
+
return getattr(v, "value", v)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _runtime_policy_to_proto(p) -> ap_pb.AgentRuntimePolicy:
|
|
87
|
+
return ap_pb.AgentRuntimePolicy(
|
|
88
|
+
model=p.model or "",
|
|
89
|
+
max_llm_calls=p.max_llm_calls,
|
|
90
|
+
max_cost_usd=p.max_cost_usd,
|
|
91
|
+
max_tokens_per_call=p.max_tokens_per_call,
|
|
92
|
+
tool_call_limits=[ap_pb.ToolCallLimit(tool=l.tool, max_calls=l.max_calls) for l in p.tool_call_limits],
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _agent_action_to_proto(action) -> ap_pb.AgentRuleAction:
|
|
97
|
+
field = action.field
|
|
98
|
+
if field == "model":
|
|
99
|
+
return ap_pb.AgentRuleAction(field=ap_pb.AGENT_RULE_ACTION_SET_MODEL, model=action.value)
|
|
100
|
+
if field == "max_llm_calls":
|
|
101
|
+
return ap_pb.AgentRuleAction(field=ap_pb.AGENT_RULE_ACTION_SET_MAX_LLM_CALLS, max_llm_calls=action.value)
|
|
102
|
+
if field == "max_cost_usd":
|
|
103
|
+
return ap_pb.AgentRuleAction(field=ap_pb.AGENT_RULE_ACTION_SET_MAX_COST_USD, max_cost_usd=action.value)
|
|
104
|
+
if field == "max_tokens_per_call":
|
|
105
|
+
return ap_pb.AgentRuleAction(field=ap_pb.AGENT_RULE_ACTION_SET_MAX_TOKENS_PER_CALL, max_tokens_per_call=action.value)
|
|
106
|
+
return ap_pb.AgentRuleAction()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _agent_rule_to_proto(rule) -> ap_pb.AgentRule:
|
|
110
|
+
return ap_pb.AgentRule(
|
|
111
|
+
metric=_AGENT_METRIC_PB.get(_enum_value(rule.metric), ap_pb.AGENT_METRIC_UNSPECIFIED),
|
|
112
|
+
op=_AGENT_OP_PB.get(_enum_value(rule.op), ap_pb.AGENT_OP_UNSPECIFIED),
|
|
113
|
+
threshold=rule.threshold,
|
|
114
|
+
window=rule.window,
|
|
115
|
+
tool=rule.tool or "",
|
|
116
|
+
action=_agent_action_to_proto(rule.action),
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def agent_policy_action_to_proto(action: dict) -> ap_pb.AgentPolicyAction:
|
|
121
|
+
"""Map the SDK-side agent policy action ({base_policy, effective_policy,
|
|
122
|
+
fired_rules:[(rule, value)]}) to the typed proto for the operation result."""
|
|
123
|
+
return ap_pb.AgentPolicyAction(
|
|
124
|
+
base_policy=_runtime_policy_to_proto(action["base_policy"]),
|
|
125
|
+
effective_policy=_runtime_policy_to_proto(action["effective_policy"]),
|
|
126
|
+
fired_rules=[
|
|
127
|
+
ap_pb.FiredAgentRule(rule=_agent_rule_to_proto(rule), trigger_value=float(value))
|
|
128
|
+
for (rule, value) in action["fired_rules"]
|
|
129
|
+
],
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class WorkerSession:
|
|
134
|
+
"""Owns the bidi stream and dispatch loop. One operation in flight at a time."""
|
|
135
|
+
|
|
136
|
+
def __init__(self, address: str, api_key: str, capabilities: list[tuple[str, int]] | None = None) -> None:
|
|
137
|
+
self._target = _strip_scheme(address)
|
|
138
|
+
self._secure = address.startswith("https://")
|
|
139
|
+
self._session_id = str(uuid.uuid4())
|
|
140
|
+
self._write_lock = asyncio.Lock()
|
|
141
|
+
self._metadata = (("x-api-key", api_key),)
|
|
142
|
+
self._capabilities = [
|
|
143
|
+
wk_pb.WorkerCapability(workflow_type=rt, workflow_version=v)
|
|
144
|
+
for rt, v in (capabilities or [])
|
|
145
|
+
]
|
|
146
|
+
|
|
147
|
+
async def run(self, dispatch: Dispatch) -> None:
|
|
148
|
+
if self._secure:
|
|
149
|
+
channel_ctx = grpc.aio.secure_channel(self._target, grpc.ssl_channel_credentials())
|
|
150
|
+
else:
|
|
151
|
+
channel_ctx = grpc.aio.insecure_channel(self._target)
|
|
152
|
+
async with channel_ctx as channel:
|
|
153
|
+
stub = wk_grpc.WorkerServiceStub(channel)
|
|
154
|
+
call = stub.WorkerSession(metadata=self._metadata)
|
|
155
|
+
await self._write(call, self._ready())
|
|
156
|
+
|
|
157
|
+
op_task: asyncio.Task | None = None
|
|
158
|
+
op_id: str | None = None
|
|
159
|
+
|
|
160
|
+
async for command in call:
|
|
161
|
+
which = command.WhichOneof("payload")
|
|
162
|
+
if which == "launch":
|
|
163
|
+
op = command.launch.operation
|
|
164
|
+
op_id = op.id
|
|
165
|
+
log.debug("launch: op_id=%s workflow_type=%s name=%s version=%d",
|
|
166
|
+
op.id, op.workflow_type, op.name, op.workflow_version)
|
|
167
|
+
# Ack IN_PROGRESS before starting; keep the receive loop free.
|
|
168
|
+
await self._write(call, self._update(op.id, op_pb.OPERATION_STATUS_IN_PROGRESS))
|
|
169
|
+
op_task = asyncio.create_task(self._run_operation(call, op, dispatch))
|
|
170
|
+
elif which == "cancel":
|
|
171
|
+
if op_task is not None and command.cancel.operation_id == op_id:
|
|
172
|
+
op_task.cancel()
|
|
173
|
+
try:
|
|
174
|
+
await op_task
|
|
175
|
+
except asyncio.CancelledError:
|
|
176
|
+
await self._write(call, self._update(op_id, op_pb.OPERATION_STATUS_CANCELLED))
|
|
177
|
+
op_task, op_id = None, None
|
|
178
|
+
await self._write(call, self._ready())
|
|
179
|
+
|
|
180
|
+
async def _run_operation(self, call, op: op_pb.AtomicOperation, dispatch: Dispatch) -> None:
|
|
181
|
+
try:
|
|
182
|
+
result = await dispatch(op)
|
|
183
|
+
except asyncio.CancelledError:
|
|
184
|
+
raise # surfaced to the main loop, which sends CANCELLED
|
|
185
|
+
except Exception as ex: # noqa: BLE001 — report a handler failure
|
|
186
|
+
log.error("operation FAILED: op_id=%s error=%s", op.id, ex, exc_info=True)
|
|
187
|
+
await self._write(call, self._update(op.id, op_pb.OPERATION_STATUS_FAILED, str(ex)))
|
|
188
|
+
await self._write(call, self._ready())
|
|
189
|
+
return
|
|
190
|
+
await self._write(call, wk_pb.WorkerMessage(
|
|
191
|
+
session_id=self._session_id,
|
|
192
|
+
update=wk_pb.OperationUpdate(operation_id=op.id, result=result),
|
|
193
|
+
))
|
|
194
|
+
await self._write(call, self._ready())
|
|
195
|
+
|
|
196
|
+
async def _write(self, call, msg: wk_pb.WorkerMessage) -> None:
|
|
197
|
+
async with self._write_lock:
|
|
198
|
+
await call.write(msg)
|
|
199
|
+
|
|
200
|
+
def _ready(self) -> wk_pb.WorkerMessage:
|
|
201
|
+
return wk_pb.WorkerMessage(
|
|
202
|
+
session_id=self._session_id,
|
|
203
|
+
ready=wk_pb.ReadyForWork(capabilities=self._capabilities),
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
def _update(self, operation_id: str, status, message: str = "") -> wk_pb.WorkerMessage:
|
|
207
|
+
return wk_pb.WorkerMessage(
|
|
208
|
+
session_id=self._session_id,
|
|
209
|
+
update=wk_pb.OperationUpdate(
|
|
210
|
+
operation_id=operation_id,
|
|
211
|
+
result=op_pb.AtomicOperationResult(status=status, message=message),
|
|
212
|
+
),
|
|
213
|
+
)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Real Anthropic API client implementing the LlmClient protocol."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import anthropic as _anthropic
|
|
5
|
+
|
|
6
|
+
from .llm import LlmClient, LlmRequest, LlmResponse, TextBlock, ToolResultBlock, ToolUseBlock, Usage
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _encode(blocks: list) -> list:
|
|
10
|
+
out = []
|
|
11
|
+
for b in blocks:
|
|
12
|
+
if isinstance(b, TextBlock):
|
|
13
|
+
out.append({"type": "text", "text": b.text})
|
|
14
|
+
elif isinstance(b, ToolUseBlock):
|
|
15
|
+
out.append({"type": "tool_use", "id": b.id, "name": b.name, "input": b.input})
|
|
16
|
+
elif isinstance(b, ToolResultBlock):
|
|
17
|
+
out.append({"type": "tool_result", "tool_use_id": b.tool_use_id,
|
|
18
|
+
"content": b.content, "is_error": b.is_error})
|
|
19
|
+
return out
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _decode(blocks) -> list:
|
|
23
|
+
out = []
|
|
24
|
+
for b in blocks:
|
|
25
|
+
if b.type == "text":
|
|
26
|
+
out.append(TextBlock(b.text))
|
|
27
|
+
elif b.type == "tool_use":
|
|
28
|
+
out.append(ToolUseBlock(b.id, b.name, b.input))
|
|
29
|
+
return out
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AnthropicLlmClient:
|
|
33
|
+
"""Wraps anthropic.AsyncAnthropic to implement LlmClient."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, api_key: str) -> None:
|
|
36
|
+
self._client = _anthropic.AsyncAnthropic(api_key=api_key)
|
|
37
|
+
|
|
38
|
+
async def complete(self, request: LlmRequest) -> LlmResponse:
|
|
39
|
+
messages = [
|
|
40
|
+
{"role": m.role, "content": _encode(m.content) if isinstance(m.content, list) else m.content}
|
|
41
|
+
for m in request.messages
|
|
42
|
+
]
|
|
43
|
+
tools = [
|
|
44
|
+
{"name": t.name, "description": t.description, "input_schema": t.input_schema}
|
|
45
|
+
for t in request.tools
|
|
46
|
+
]
|
|
47
|
+
# Caching the system block also caches the tools (render order is
|
|
48
|
+
# tools -> system -> messages), so one breakpoint covers the stable prefix.
|
|
49
|
+
system = request.system
|
|
50
|
+
if request.cache:
|
|
51
|
+
system = [{"type": "text", "text": request.system,
|
|
52
|
+
"cache_control": {"type": "ephemeral"}}]
|
|
53
|
+
|
|
54
|
+
kwargs: dict = dict(
|
|
55
|
+
model=request.model,
|
|
56
|
+
max_tokens=request.max_tokens,
|
|
57
|
+
system=system,
|
|
58
|
+
messages=messages,
|
|
59
|
+
tools=tools,
|
|
60
|
+
)
|
|
61
|
+
if request.forced_tool:
|
|
62
|
+
kwargs["tool_choice"] = {"type": "tool", "name": request.forced_tool}
|
|
63
|
+
|
|
64
|
+
resp = await self._client.messages.create(**kwargs)
|
|
65
|
+
stop = resp.stop_reason
|
|
66
|
+
# Treat max_tokens like end_turn so the orchestrator can re-prompt with submit_result.
|
|
67
|
+
if stop == "max_tokens":
|
|
68
|
+
stop = "end_turn"
|
|
69
|
+
u = resp.usage
|
|
70
|
+
return LlmResponse(
|
|
71
|
+
content=_decode(resp.content),
|
|
72
|
+
stop_reason=stop,
|
|
73
|
+
usage=Usage(
|
|
74
|
+
u.input_tokens,
|
|
75
|
+
u.output_tokens,
|
|
76
|
+
getattr(u, "cache_creation_input_tokens", 0) or 0,
|
|
77
|
+
getattr(u, "cache_read_input_tokens", 0) or 0,
|
|
78
|
+
),
|
|
79
|
+
)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""boundflow — BoundFlow control plane CLI."""
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from boundflow.cli._client import configure
|
|
6
|
+
from boundflow.cli._output import set_json
|
|
7
|
+
from boundflow.cli.commands import audit, policies, pricing, tenants, workflows
|
|
8
|
+
|
|
9
|
+
app = typer.Typer(
|
|
10
|
+
name="boundflow",
|
|
11
|
+
help="BoundFlow control plane CLI — manage workflows, policies, and audit logs.",
|
|
12
|
+
no_args_is_help=True,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
app.add_typer(tenants.app, name="tenant")
|
|
16
|
+
app.add_typer(workflows.app, name="workflow")
|
|
17
|
+
app.add_typer(policies.app, name="policy")
|
|
18
|
+
app.add_typer(audit.app, name="audit")
|
|
19
|
+
app.add_typer(pricing.app, name="pricing")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@app.callback()
|
|
23
|
+
def root(
|
|
24
|
+
server: str = typer.Option(
|
|
25
|
+
"", "--server", envvar="BOUNDFLOW_SERVER_ADDRESS",
|
|
26
|
+
help="gRPC server address (default: http://localhost:50051)",
|
|
27
|
+
),
|
|
28
|
+
api_key: str = typer.Option(
|
|
29
|
+
"", "--api-key", envvar="BOUNDFLOW_API_KEY",
|
|
30
|
+
help="BoundFlow API key",
|
|
31
|
+
),
|
|
32
|
+
json_output: bool = typer.Option(
|
|
33
|
+
False, "--json",
|
|
34
|
+
help="Output raw JSON (useful for scripting)",
|
|
35
|
+
),
|
|
36
|
+
):
|
|
37
|
+
set_json(json_output)
|
|
38
|
+
configure(server, api_key)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def main() -> None:
|
|
42
|
+
app()
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Async bridge between sync Typer commands and the async ControlPlaneClient."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from boundflow.control_plane import ControlPlaneClient, DEFAULT_SERVER_ADDRESS
|
|
9
|
+
|
|
10
|
+
_server: str = DEFAULT_SERVER_ADDRESS
|
|
11
|
+
_api_key: str = ""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def configure(server: str, api_key: str) -> None:
|
|
15
|
+
# Runs in the root callback for every invocation — including `--help` — so it
|
|
16
|
+
# only resolves config, never fails. The API key is required lazily in cp_call,
|
|
17
|
+
# when a command actually calls the control plane.
|
|
18
|
+
global _server, _api_key
|
|
19
|
+
_server = server or os.environ.get("BOUNDFLOW_SERVER_ADDRESS") or DEFAULT_SERVER_ADDRESS
|
|
20
|
+
_api_key = api_key or os.environ.get("BOUNDFLOW_API_KEY") or ""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def cp_call(fn):
|
|
24
|
+
"""Open a ControlPlaneClient, call fn(client), close, return result.
|
|
25
|
+
|
|
26
|
+
Converts any exception into a user-facing error message + Exit(1).
|
|
27
|
+
"""
|
|
28
|
+
if not _api_key:
|
|
29
|
+
typer.echo("Error: no API key. Set BOUNDFLOW_API_KEY or pass --api-key.", err=True)
|
|
30
|
+
raise typer.Exit(1)
|
|
31
|
+
|
|
32
|
+
async def _run():
|
|
33
|
+
async with ControlPlaneClient(_server, api_key=_api_key) as cp:
|
|
34
|
+
return await fn(cp)
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
return asyncio.run(_run())
|
|
38
|
+
except typer.Exit:
|
|
39
|
+
raise
|
|
40
|
+
except Exception as exc:
|
|
41
|
+
typer.echo(f"Error: {exc}", err=True)
|
|
42
|
+
raise typer.Exit(1)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Rich table rendering and --json output helpers."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import asdict, is_dataclass
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from enum import Enum
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
|
|
12
|
+
console = Console()
|
|
13
|
+
_json_mode: bool = False
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def set_json(value: bool) -> None:
|
|
17
|
+
global _json_mode
|
|
18
|
+
_json_mode = value
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _normalize(value):
|
|
22
|
+
"""Recursively convert enum instances to their .value, so table display
|
|
23
|
+
and JSON output both show plain strings rather than 'EnumClass.member'."""
|
|
24
|
+
if isinstance(value, Enum):
|
|
25
|
+
return value.value
|
|
26
|
+
if isinstance(value, dict):
|
|
27
|
+
return {k: _normalize(v) for k, v in value.items()}
|
|
28
|
+
if isinstance(value, list):
|
|
29
|
+
return [_normalize(v) for v in value]
|
|
30
|
+
return value
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _to_dict(obj) -> dict:
|
|
34
|
+
if is_dataclass(obj):
|
|
35
|
+
raw = asdict(obj)
|
|
36
|
+
elif isinstance(obj, dict):
|
|
37
|
+
raw = obj
|
|
38
|
+
else:
|
|
39
|
+
raw = vars(obj)
|
|
40
|
+
return _normalize(raw)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _json_default(o):
|
|
44
|
+
if isinstance(o, datetime):
|
|
45
|
+
return o.isoformat()
|
|
46
|
+
return str(o)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def output(data) -> None:
|
|
50
|
+
"""Render a single record (dict/dataclass) or a list of them."""
|
|
51
|
+
if isinstance(data, list):
|
|
52
|
+
rows = [_to_dict(r) for r in data]
|
|
53
|
+
if _json_mode:
|
|
54
|
+
typer.echo(json.dumps(rows, default=_json_default, indent=2))
|
|
55
|
+
else:
|
|
56
|
+
_table(rows)
|
|
57
|
+
else:
|
|
58
|
+
rec = _to_dict(data)
|
|
59
|
+
if _json_mode:
|
|
60
|
+
typer.echo(json.dumps(rec, default=_json_default, indent=2))
|
|
61
|
+
else:
|
|
62
|
+
_record(rec)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _table(rows: list[dict]) -> None:
|
|
66
|
+
if not rows:
|
|
67
|
+
console.print("[dim]No results.[/dim]")
|
|
68
|
+
return
|
|
69
|
+
t = Table(show_header=True, header_style="bold cyan")
|
|
70
|
+
for key in rows[0]:
|
|
71
|
+
t.add_column(str(key))
|
|
72
|
+
for row in rows:
|
|
73
|
+
t.add_row(*[str(v) if v is not None else "" for v in row.values()])
|
|
74
|
+
console.print(t)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _record(rec: dict) -> None:
|
|
78
|
+
t = Table(show_header=False, box=None, padding=(0, 1))
|
|
79
|
+
t.add_column("Key", style="bold cyan", no_wrap=True)
|
|
80
|
+
t.add_column("Value")
|
|
81
|
+
for k, v in rec.items():
|
|
82
|
+
t.add_row(str(k), str(v) if v is not None else "")
|
|
83
|
+
console.print(t)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def success(msg: str) -> None:
|
|
87
|
+
if _json_mode:
|
|
88
|
+
typer.echo(json.dumps({"status": "ok", "message": msg}))
|
|
89
|
+
else:
|
|
90
|
+
console.print(f"[green]{msg}[/green]")
|
|
File without changes
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""boundflow audit — approval and policy audit log commands."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import asdict
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from boundflow.cli._client import cp_call
|
|
9
|
+
from boundflow.cli._output import output
|
|
10
|
+
|
|
11
|
+
app = typer.Typer(help="View approval and policy audit records.")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _flatten(record) -> dict:
|
|
15
|
+
"""Flatten a dataclass to a dict, serialising nested structures to strings."""
|
|
16
|
+
d = asdict(record) if hasattr(record, "__dataclass_fields__") else vars(record)
|
|
17
|
+
return {k: str(v) if isinstance(v, (list, dict)) else v for k, v in d.items()}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@app.command("approvals")
|
|
21
|
+
def approvals(
|
|
22
|
+
workflow_id: str = typer.Argument(..., help="Workflow ID"),
|
|
23
|
+
approval_id: Optional[str] = typer.Option(None, "--approval-id", help="Look up a single approval by ID"),
|
|
24
|
+
):
|
|
25
|
+
"""List approval decisions for a workflow (or fetch one by approval ID)."""
|
|
26
|
+
if approval_id:
|
|
27
|
+
result = cp_call(lambda cp: cp.get_approval_audit_by_id(approval_id))
|
|
28
|
+
if result is None:
|
|
29
|
+
typer.echo(f"No approval found with ID {approval_id}.", err=True)
|
|
30
|
+
raise typer.Exit(1)
|
|
31
|
+
output(_flatten(result))
|
|
32
|
+
else:
|
|
33
|
+
results = cp_call(lambda cp: cp.get_approval_audit(workflow_id))
|
|
34
|
+
output([_flatten(r) for r in results])
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@app.command("workflow")
|
|
38
|
+
def workflow_policy(
|
|
39
|
+
workflow_id: str = typer.Argument(..., help="Workflow ID"),
|
|
40
|
+
):
|
|
41
|
+
"""List workflow-lifecycle policy firings for a workflow."""
|
|
42
|
+
results = cp_call(lambda cp: cp.get_workflow_policy_audit(workflow_id))
|
|
43
|
+
output([_flatten(r) for r in results])
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@app.command("agent")
|
|
47
|
+
def agent_policy(
|
|
48
|
+
workflow_id: str = typer.Argument(..., help="Workflow ID"),
|
|
49
|
+
agent_name: str = typer.Argument(..., help="Agent name"),
|
|
50
|
+
):
|
|
51
|
+
"""List agent-lifecycle policy firings for a specific agent."""
|
|
52
|
+
results = cp_call(lambda cp: cp.get_agent_policy_audit(workflow_id, agent_name))
|
|
53
|
+
output([_flatten(r) for r in results])
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@app.command("log")
|
|
57
|
+
def log(
|
|
58
|
+
workflow_id: Optional[str] = typer.Argument(None, help="Workflow ID (omit for tenant-wide log)"),
|
|
59
|
+
):
|
|
60
|
+
"""Show the unified audit log (approvals + policy firings, newest first)."""
|
|
61
|
+
results = cp_call(lambda cp: cp.get_audit_log(workflow_id or ""))
|
|
62
|
+
output([_flatten(r) for r in results])
|