Run Terminal
Fold racing terminal signals into one authoritative outcome โ no last-writer-wins
Several observers can watch the same run at once โ an idle watchdog, a run-budget timer, an external /stop, a provider client โ and RunTerminal folds their racing signals into one authoritative outcome that only ever refines toward stronger attribution.
Quick Start
- Simplest merge (no current)
With no recorded outcome, the observation becomes authoritative.
Python
python from praisonaiagents import RunTerminal, merge_run_terminal observed = RunTerminal("failed", "provider") outcome = merge_run_terminal(None, observed) print(outcome) # RunTerminal(kind='failed', source='provider', detail=None)TypeScript
ts import { RunTerminal, mergeRunTerminal } from 'praisonai'; const observed = new RunTerminal({ kind: 'failed', source: 'provider' }); const outcome = mergeRunTerminal(null, observed); console.log(outcome); // RunTerminal { kind: 'failed', source: 'provider', detail: undefined } - Deliberate cancel is never downgraded
A user’s
/stopwins over a late provider error, whatever the arrival order.Python
python from praisonaiagents import RunTerminal, merge_run_terminal, collapse cancel = RunTerminal("aborted", "external", detail="user /stop") late_error = RunTerminal("failed", "provider") outcome = merge_run_terminal(cancel, late_error) print(collapse(outcome)) # 'cancelled' โ the user's /stop winsTypeScript
ts import { RunTerminal, mergeRunTerminal, collapse } from 'praisonai'; const cancel = new RunTerminal({ kind: 'aborted', source: 'external', detail: 'user /stop' }); const lateError = new RunTerminal({ kind: 'failed', source: 'provider' }); const outcome = mergeRunTerminal(cancel, lateError); console.log(collapse(outcome)); // 'cancelled' โ the user's /stop wins - Round-trip through a durable ledger
Persist the outcome typed, not as a free string โ then rehydrate it.
Python
python from praisonaiagents import RunTerminal outcome = RunTerminal("aborted", "external", detail="user /stop") # Persist to SQLite / JSON / whatever ledger you use data = outcome.to_dict() # {'kind': 'aborted', 'source': 'external', 'detail': 'user /stop'} # Restart, rehydrate restored = RunTerminal.from_dict(data) assert restored == outcome # Python: value equality (frozen dataclass)TypeScript
ts import { RunTerminal } from 'praisonai'; const outcome = new RunTerminal({ kind: 'aborted', source: 'external', detail: 'user /stop' }); // Persist to SQLite / JSON / whatever ledger you use const data = outcome.toDict(); // { kind: 'aborted', source: 'external', detail: 'user /stop' } // Restart, rehydrate const restored = RunTerminal.fromDict(data); // TS instances are not value-equal (restored !== outcome) โ compare toDict() output instead. console.assert(JSON.stringify(restored.toDict()) === JSON.stringify(outcome.toDict()));
How It Works
Each observer reports a RunTerminal to merge_run_terminal, which keeps refining one authoritative outcome; collapse projects it to a RunStatus for downstream consumers.
| Concept | What it does |
|---|---|
RunTerminal | Frozen dataclass โ one terminal observation (kind + source + detail) |
merge_run_terminal | Refines the recorded terminal toward stronger attribution; never downgrades |
is_sticky | Marks a terminal authoritative โ never overwritten |
collapse | Projects RunTerminal onto the existing RunStatus vocabulary |
Precedence & Stickiness
merge_run_terminal picks a winner by two rules: a stronger kind always beats a weaker one, and among equal kinds a sticky source beats a non-sticky one.
Kind precedence ladder โ a later, weaker observation never overwrites a stronger one.
Sticky sources โ a deliberate cancel, a hard run-budget timeout, and a supersede are intentional terminals; once recorded, authoritative.
Configuration Options
RunTerminal is a frozen dataclass (Python) / frozen class (TypeScript). Every field:
Python
| Field | Type | Default | Description | |||
|---|---|---|---|---|---|---|
kind | TerminalKind | required | Collapsed terminal category โ `”ok” \ | “failed” \ | “timeout” \ | “aborted”` |
source | TerminalSource | required | Which observer produced the terminal (decides stickiness) | |||
detail | Optional[str] | None | Free-form detail, e.g. "user /stop" |
Methods:
| Method | Signature | Purpose |
|---|---|---|
to_dict() | -> Dict[str, Any] | Serialise to a JSON/SQLite friendly dict |
from_dict(data) | classmethod -> RunTerminal | Rehydrate from a dict produced by to_dict |
TypeScript
| Field | Type | Default | Description | |||
|---|---|---|---|---|---|---|
kind | TerminalKind (readonly) | required | Collapsed terminal category โ `’ok’ \ | ‘failed’ \ | ‘timeout’ \ | ‘aborted’` |
source | TerminalSource (readonly) | required | Which observer produced the terminal (decides stickiness) | |||
detail | `string \ | undefined` (readonly) | undefined | Free-form detail, e.g. 'user /stop' |
Methods:
| Method | Signature | Purpose | |
|---|---|---|---|
toDict() | `() => { kind, source, detail: string \ | null }` | Serialise to a JSON-friendly object (absent detail becomes null) |
static fromDict(data) | (data) => RunTerminal | Rehydrate from a toDict()-produced object |
The source vocabulary and which sources are sticky (identical for both SDKs):
source | Sticky? | Typical observer |
|---|---|---|
completion | No | Normal successful finish |
idle | No | Idle-timeout watchdog |
run_budget | Yes | Hard run-budget / total-timeout guard |
external | Yes | User /stop, upstream cancel |
provider | No | LLM/tool error from the provider |
superseded | Yes | A newer turn replaced this one |
Full API surface lives in the auto-generated SDK reference for praisonaiagents.run_outcome.
Which source to pick
Pick the source that names the observer that ended the run.
Common Patterns
Gateway race โ an idle timeout arrives, then the user cancels; the cancel wins:
Python
from praisonaiagents import RunTerminal, merge_run_terminal, collapse
terminal = None
terminal = merge_run_terminal(terminal, RunTerminal("timeout", "idle"))
terminal = merge_run_terminal(terminal, RunTerminal("aborted", "external"))
print(collapse(terminal)) # 'cancelled' โ the user's cancel wins over the idle timeoutTypeScript
import { RunTerminal, mergeRunTerminal, collapse } from 'praisonai';
let terminal: RunTerminal | null = null;
terminal = mergeRunTerminal(terminal, new RunTerminal({ kind: 'timeout', source: 'idle' }));
terminal = mergeRunTerminal(terminal, new RunTerminal({ kind: 'aborted', source: 'external' }));
console.log(collapse(terminal)); // 'cancelled' โ the user's cancel wins over the idle timeoutOrder-independent merge โ the same signals yield the same result regardless of arrival order:
Python
from praisonaiagents import RunTerminal, merge_run_terminal
idle = RunTerminal("timeout", "idle")
run_budget = RunTerminal("timeout", "run_budget")
forward = merge_run_terminal(merge_run_terminal(None, idle), run_budget)
reverse = merge_run_terminal(merge_run_terminal(None, run_budget), idle)
assert forward == reverse == run_budget # sticky run_budget wins either wayTypeScript
import { RunTerminal, mergeRunTerminal } from 'praisonai';
const idle = new RunTerminal({ kind: 'timeout', source: 'idle' });
const runBudget = new RunTerminal({ kind: 'timeout', source: 'run_budget' });
const forward = mergeRunTerminal(mergeRunTerminal(null, idle), runBudget);
const reverse = mergeRunTerminal(mergeRunTerminal(null, runBudget), idle);
// Sticky run_budget wins either way โ compare toDict() (TS instances aren't value-equal).
console.assert(JSON.stringify(forward.toDict()) === JSON.stringify(reverse.toDict()));
console.assert(forward.source === 'run_budget');Durable ledger persistence โ typed, not a free string:
Python
# Before: record.terminal_outcome = str(reason) # untyped free string
# After:
record.terminal = merge_run_terminal(record.terminal, observed) # refine-only
record.status = collapse(record.terminal) # RunStatus for downstreamTypeScript
// Before: record.terminalOutcome = String(reason); // untyped free string
// After:
record.terminal = mergeRunTerminal(record.terminal, observed); // refine-only
record.status = collapse(record.terminal); // AgentRunStatus for downstreamBest Practices
Always merge, never assign
Replacing record.terminal with the last observation is the exact bug this contract fixes. Always call merge_run_terminal(record.terminal, observed) so a late provider error can never overwrite a deliberate cancel or a hard timeout.
Persist typed via to_dict
Store outcome.to_dict() (Python) or outcome.toDict() (TypeScript) in your durable ledger and rehydrate with RunTerminal.from_dict(...) / RunTerminal.fromDict(...). The free-string form is the ambiguity this contract removes โ keep the outcome typed end to end.
Collapse only at the boundary
Keep RunTerminal internally and call collapse() only when you must emit a legacy RunStatus โ a chat reaction, an exit code, an HTTP status. Collapsing early throws away the source attribution.
Use is_sticky to stop watching
Once a terminal is sticky, its outcome cannot change. Check is_sticky(outcome) (Python) / isSticky(outcome) (TypeScript) and stop watching the run โ further observations can only be discarded anyway.