Skip to main content

Execution, approvals, and recovery

These optional primitives let applications supply their own roles, workflows, policies, storage, credentials, and UI while ProtoLink handles execution and lifecycle. They cover command execution, recoverable file changes, approvals, delegated evidence, completion checks, and checkpoint inventory without adding base-package dependencies.

Built-in execution tools

The Built-in Tools catalog documents command execution, shell and Git, user feedback, and filesystem access and recovery. Register those optional tools on any Agent; this page explains the runtime facilities shared by built-in and application-defined tools.

Embedded groups and run handles

from protolink import AgentGroup, Task

async with AgentGroup([agent]) as group:
handle = group.run(
"commands",
Task.create_tool_call(
tool_name="execute_command",
args={"argv": ["/absolute/executable"], "cwd": "/absolute/directory", "env": {}},
),
)
async for event in handle.events():
render(event) # Application presentation.
if application_wants_to_stop():
await handle.cancel("User stopped the run")
result = await handle.result()
print(result.status, result.output)
report = result.report

The runnable embedded_group.py cancels a harmless process after its first output.

AgentGroup(agents, *, external_agents=(), registry=None, own_registry=False, client=None, startup_timeout=10) starts and stops the agents in agents. It waits for transport/card readiness and successful configured registration, and rolls back partially started resources if startup fails. Shutdown cancels runs submitted through the group and stops owned resources in reverse order. Externally supplied agents, the client, and the registry are left running; set own_registry=True to include the registry in the group's lifecycle. Ownership of an agent includes its server and transport. Avoid sharing an owned transport with resources whose lifecycle is external to the group.

Configure each Agent normally, including its LLM, transport, policy, approval handler, credentials, and storage. Transport-free agents use direct invocation; runtime:// supports local discovery and delegation without sockets. Network transports use their existing implementations. The group adds no global registry or orchestration roles.

For live model output, enable capabilities={"streaming": True} on the Agent card and inspect event.payload.get("llm_event_type"). llm_chunk carries incremental text in event.payload["content"]; llm_final carries the complete answer. JSON-action models stream raw JSON fragments, while native-tool models stream ordinary text and assemble tool calls separately. Continue to the terminal task status before treating the whole run as complete. See the embedded streaming example.

RunHandle.start(agent_or_url, task, *, client=None, store=None, redaction_policy=None) is also usable without a group. URL targets require an existing AgentClient. The handle consumes the task once, even when only result() is awaited. events() yields typed RunEvent objects, including history for later subscribers. cancel(reason) uses native cancellation; cancellation of a waiter on result() does not cancel the underlying run.

RunResult exposes status, the normalized task, output (unwrapped tool result or final response), report, and an optional structured error. Task statuses include completed, failed, canceled, and input_required. If a remote stream closes without a terminal task, status is uncertain: the effect may already have occurred. Task submission keeps response deduplication but disables automatic transport retries, including when a RetryPolicy permits retries for other operations. The handle never retries or replays that operation. HTTP transports without streaming return a final task and its recorded native events; streaming transports provide live events. handle.report provides an interim or final RunReport. Reports use an explicit store or the local Agent's existing run_store when configured.

Delegated worker evidence

Model-driven delegation automatically includes worker events and execution receipts in the parent's stream, task.metadata["run_events"], and RunReport. Native peers advertising streaming on a capable transport deliver events while the worker runs. A worker overriding only handle_task() is advertised without streaming so its custom handler remains authoritative. Other peers and older call_agent() overrides contribute receipts from the returned task snapshot. A2A peers contribute only the evidence present in their mapped task response.

Worker event_id, run_id, task_id, agent_name, and action_id stay intact. parent_action_id and delegation_id link worker events to the calling action; existing links from nested delegation are preserved. The parent stream assigns its own sequence numbers and retains source_sequence, source_final, and parent_run_id in envelope metadata. Forwarded child events have final=False; the original payload is unchanged. Only the parent's terminal task status closes the parent run. Returned artifacts are also retained on the parent.

Streamed events and final snapshot receipts are deduplicated by event ID. Failed and canceled workers retain observed evidence. A disconnected stream or missing terminal task fails the delegation with unknown remote effects and is never resubmitted. CompletionValidator can inspect executed worker outcomes directly from the parent; an agent.call receipt by itself does not count as a tool execution.

Approval adapters and reconnects

from protolink import ApprovalBroker, ApprovalDecision, ApprovalScope
from protolink.storage import SQLiteStorage

broker = ApprovalBroker(
storage=SQLiteStorage("application.db", namespace="approvals"),
timeout_seconds=120,
)
# Supply approval_handler=broker when constructing the Agent.
# Derive this scope from the authenticated caller, never from untrusted UI data.
scope = ApprovalScope(frozenset({authorized_run_id}))
for pending in broker.pending(scope):
show_approval(pending.request.to_dict(), pending.fingerprint)

resolution = broker.resolve(
ApprovalDecision(approved=True, request_id=displayed_request_id),
scope=scope,
fingerprint=displayed_fingerprint,
)

approval_adapter.py shows a separate adapter coroutine consuming broker.events(scope) while the Agent waits.

ApprovalBroker implements the existing ApprovalHandler. Each pending request has a stable request ID and a fingerprint of the complete prepared RunAction, including artifacts. Decisions with mismatched request IDs or changed prepared actions cannot authorize execution. The broker handles multiple pending requests independently.

resolve() returns an ApprovalResolution with one of approved, denied, duplicate, already_resolved, stale, expired, or unknown. Repeating the identical decision is a harmless duplicate; a different decision for a resolved request returns already_resolved. Unknown and out-of-scope IDs both return unknown. A fingerprint is correlation data, not a credential. The application authenticates callers and constructs the allowed scope server-side.

Task cancellation unblocks the wait and marks the request canceled. records(scope) returns detached snapshots; pending(scope) returns outstanding requests; each events(scope) subscription yields pending snapshots and later resolution records. Close unused subscriptions. Reconnecting to the same broker preserves pending waits. One live broker owns its Storage namespace on one event loop; it is not a distributed multi-writer approval service.

Reopening durable storage is inspection only. Orphan pending requests become uncertain and cannot release an action. Approval records carry effect_state="unknown": approval itself cannot prove that an effect happened. Consult execution receipts and the actual resource. Duplicate requests or reconnects never resume execution.

Checkpoint inventory

The filesystem tools use checkpoints to record recoverable changes. See write recovery for configuration, mutation, conflict checks, and restoration.

recent = checkpoints.list_changes(limit=20)
uncertain = checkpoints.list_changes(state="uncertain", resource_id="/absolute/workspace/note.txt")
run_changes = checkpoints.list_changes(run_id=run_id, task_id=task_id, limit=20, offset=20)

list_changes(*, limit=100, offset=0, state=None, resource_id=None, run_id=None, task_id=None) returns detached ResourceChange records, most recently inserted first. Filters match exactly and combine with AND; pagination applies after filtering. Negative limits or offsets raise ValueError; a zero limit returns an empty list. Updating or restoring a record does not move it. Run/task filters identify the original write, with restoration identifiers available on each result. The Storage adapter loads its dedicated namespace once per query.

Inventory does not inspect files, alter states, or resume work. Results contain original recovery bytes and need the same protection as get(change_id). Use a redacted presentation copy for a UI; preserve the protected record for restoration. Existing custom checkpoint stores need to implement list_changes() to expose inventory.

See the checkpoint storage API reference for the constructor, method signatures, individual parameters, returned fields, and errors.

Completion evidence and bounded workflows

from protolink import CompletionCheck, CompletionValidator, RunReport

validator = CompletionValidator(
[
CompletionCheck(
"command succeeded",
lambda evidence: evidence.outcomes[-1].result["exit_code"] == 0,
action_ids=(executed_action_id,),
)
]
)
checks = await validator.validate(final_task, report=final_report)
assert all(check.passed for check in checks)
updated_report = RunReport.from_task(final_task)

CompletionEvidence supplies the typed Task, executed ToolOutcome records, artifacts, and RunReport to each predicate. Predicates return bool or ValidationResult, synchronously or asynchronously. A check requires an execution receipt by default; use explicit action_ids to bind it to particular operations. A proposed action, preview, or approval alone produces blocked / execution_evidence_missing. Pure answer predicates may explicitly set require_execution=False. Old inference receipts can prove execution while omitting private tool output; predicates needing that output must use an available structured result or artifact.

For resource-dependent evidence, provide revisions=(ResourceRevision(...),) and read_revision=lambda resource_id: resource.read(resource_id).revision. Versions are checked before and after the predicate; changed resources yield stale / resource_revision_changed. ValidationResult.is_current(revisions) allows an adapter to assess stored evidence later. A historical passed result is not a permanent assertion of freshness.

Validation emits validation.completed, appears in RunReport.validations, and participates in existing report comparisons. Results distinguish passed, failed, blocked, and stale, with optional structured codes/messages. Validation records its result without changing terminal task state or choosing a repair policy. Native runtime events separate action.requested, approval.required, approval.decided, action.approved, action.started, action.completed, and validation.completed. Policy-allowed actions proceed without a human approval event.

Use the existing Graph, Pipeline, and Router to choose your workflow:

from protolink import Graph, Pipeline

graph = Graph(max_iterations=8, max_node_visits={"repair": 3})
# Add application nodes, conditions, and edges normally.
pipeline = Pipeline(application_steps, max_steps=5)

Graph retains its default 50-iteration bound; max_node_visits may be a per-node mapping or one limit for all nodes. Pipeline optionally caps executed steps. Nested bounded flows share RunBudget counters and runtime limits; workflow dispatches and ordinary agent/tool/model steps count toward max_steps. A limit raises WorkflowLimitError or BudgetExceededError, records a structured blocker, and prevents the next dispatch. Cancellation stops traversal. Workflow nodes use separate task identities while the enclosing task stays cancelable and retains its ID and partial outputs. Denied, failed, canceled, or input-required work does not silently become a new attempt. Limits on repair visits are independent of transport RetryPolicy; the runtime does not retry denied actions or replay side effects after uncertain transport failures.

verified_workflow.py shows application-defined acceptance with at most three attempts and no model provider. It uses RepeatUntil(ToolStep(agent, "measure"), check, max_attempts=3); checks receive fresh execution receipts on each attempt. See progressive control for callable steps, bounded acceptance, and the explicit Graph alternative.

Migration for an application such as ProtoAgent

Application plumbingProtoLink replacement
Custom subprocess launch, pipe drains, timeout/cancellation handlingRegister process_tool() and submit Task.create_tool_call(...).
Per-agent startup/shutdown and partial-start rollbackasync with AgentGroup(owned, external_agents=external, ...).
Parsing several final stream shapesawait handle.result() and its normalized task/output/report.
Pending approval maps and decision futuresApprovalBroker, with an authenticated adapter supplying ApprovalScope.
Saving original files and implementing restore conflict checksfilesystem_tools(...) with a dedicated StorageCheckpointStore.
Treating preview or approval as execution successCompletionValidator over action receipts and current resource revisions.
Unbounded repair loopsExisting Graph/Pipeline with explicit visit/step limits.

Keep roles, prompts, domain knowledge, task routing, configuration, credentials, user interface, and acceptance criteria in the application. Existing RunStore/SQLiteRunStore, RunRecorder, RunReport, report assertions/comparisons, CapabilityPolicy, task cancellation, transports, and per-agent LLM configuration remain the underlying facilities. RunReplay remains read-only inspection. Full task suspension/resumption and conversation branching are outside this release.

Use existing RedactionPolicy when exporting approval requests, events, and reports. Environment values retain their original keys so sensitive-key masking works. Output strings can contain arbitrary secrets: configure output fields as sensitive or supply an application redaction policy where needed. Redaction does not mutate execution arguments or lossless recovery storage, and the library does not automatically discover secrets embedded in command/output text.