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.

Diagram

Run Terminal

โณ Idle Timeout

๐Ÿ”€ merge_run_terminal

โฐ Run Budget

๐Ÿ›‘ External /stop

โŒ Provider Error

โ™ป๏ธ Superseded

โœ… Completion

๐Ÿ“‹ RunTerminal

๐Ÿ“‰ collapse

๐ŸŽฏ RunStatus

Quick Start

  1. 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 }
  2. Deliberate cancel is never downgraded

    A user’s /stop wins 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 wins

    TypeScript

    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
  3. 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.

Diagram
RunStatusmerge_run_terminalProvider ClientExternal /stopRun-Budget TimerIdle WatchdogRunStatusmerge_run_terminalProvider ClientExternal /stopRun-Budget TimerIdle WatchdogSticky run_budget wins โ€” ‘timeout’RunTerminal(“timeout”, “idle”)RunTerminal(“timeout”, “run_budget”)RunTerminal(“aborted”, “external”)RunTerminal(“failed”, “provider”)collapse(outcome)
ConceptWhat it does
RunTerminalFrozen dataclass โ€” one terminal observation (kind + source + detail)
merge_run_terminalRefines the recorded terminal toward stronger attribution; never downgrades
is_stickyMarks a terminal authoritative โ€” never overwritten
collapseProjects 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.

Diagram

Kind Precedence (higher wins)

ok ยท 0

failed ยท 1

timeout ยท 2

aborted ยท 3

Sticky sources โ€” a deliberate cancel, a hard run-budget timeout, and a supersede are intentional terminals; once recorded, authoritative.

Diagram

external

๐Ÿ”’ sticky ยท never downgraded

run_budget

superseded


Configuration Options

RunTerminal is a frozen dataclass (Python) / frozen class (TypeScript). Every field:

Python

FieldTypeDefaultDescription
kindTerminalKindrequiredCollapsed terminal category โ€” `”ok” \“failed” \“timeout” \“aborted”`
sourceTerminalSourcerequiredWhich observer produced the terminal (decides stickiness)
detailOptional[str]NoneFree-form detail, e.g. "user /stop"

Methods:

MethodSignaturePurpose
to_dict()-> Dict[str, Any]Serialise to a JSON/SQLite friendly dict
from_dict(data)classmethod -> RunTerminalRehydrate from a dict produced by to_dict

TypeScript

FieldTypeDefaultDescription
kindTerminalKind (readonly)requiredCollapsed terminal category โ€” `’ok’ \‘failed’ \‘timeout’ \‘aborted’`
sourceTerminalSource (readonly)requiredWhich observer produced the terminal (decides stickiness)
detail`string \undefined` (readonly)undefinedFree-form detail, e.g. 'user /stop'

Methods:

MethodSignaturePurpose
toDict()`() => { kind, source, detail: string \null }`Serialise to a JSON-friendly object (absent detail becomes null)
static fromDict(data)(data) => RunTerminalRehydrate from a toDict()-produced object

The source vocabulary and which sources are sticky (identical for both SDKs):

sourceSticky?Typical observer
completionNoNormal successful finish
idleNoIdle-timeout watchdog
run_budgetYesHard run-budget / total-timeout guard
externalYesUser /stop, upstream cancel
providerNoLLM/tool error from the provider
supersededYesA 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.

Diagram

Yes

No

Yes

No

Yes

No

Yes

No

Yes

No

๐Ÿค– Run ended

Finished normally?

completion

Upstream user cancel?

external

Hard budget elapsed?

run_budget

Replaced by a newer turn?

superseded

LLM / tool provider error?

provider

idle


Common Patterns

Gateway race โ€” an idle timeout arrives, then the user cancels; the cancel wins:

Python

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 timeout

TypeScript

ts
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 timeout

Order-independent merge โ€” the same signals yield the same result regardless of arrival order:

Python

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 way

TypeScript

ts
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

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 downstream

TypeScript

ts
// Before: record.terminalOutcome = String(reason);       // untyped free string
// After:
record.terminal = mergeRunTerminal(record.terminal, observed);  // refine-only
record.status   = collapse(record.terminal);                    // AgentRunStatus for downstream

Best 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.


Related