8000 feat(agentcore): add durable execution loop by Fodesu · Pull Request #89 · felinics/twilight · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
197 changes: 197 additions & 0 deletions agentcore/run/loop/contract.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
package loop

import (
"context"
"encoding/json"
"errors"
"time"

"github.com/felinics/twilight/agentcore/decision"
run "github.com/felinics/twilight/agentcore/run"
effect "github.com/felinics/twilight/agentcore/run/effect"
"github.com/felinics/twilight/agentcore/run/store"
)

// ErrRunAlreadyRunning identifies a second local driver for the same Run.
// A Loop permits concurrent execution of different Runs and serializes each
// Run locally so every Executing target has one in-process owner (RUN-CMT-6).
var ErrRunAlreadyRunning = errors.New("agent: loop: run already running")

// EffectContext identifies the effect a target is resolved for: the Run's
// Scope and identity, the Step, the call of a tool effect, the EffectID the
// effect is about to be started under, the kind of the effect and, for a tool
// effect, the tool's ref.
type EffectContext struct {
Session run.Scope
RunID run.RunID
StepID run.StepID
CallID run.CallID
Effect run.EffectID
Kind AssignmentKind
Tool run.ToolRef
// Placement is the tool's declared placement for a tool effect; a
// resolver supplies a workspace target only for PlacementWorkspace.
Placement run.ToolPlacement
}

// TargetResolver supplies an opaque target for one effect (RUN-LOP-9). It
// belongs to the application/resource layer: the Loop asks it once for every
// effect it is about to start, before the start barrier, and only copies the
// returned reference into that effect's Assignment. A nil target means the
// effect has no resource target. The mapping must be durable when a Run can
// outlive the process that started it.
type TargetResolver interface {
ResolveTarget(context.Context, EffectContext) (*run.TargetRef, error)
}

// Settings are the execution parameters the Loop takes from the AgentPreset
// (RUN-LOP-1). Scheduling is frozen onto each ToolStep; MalformedRetries
// bounds the retries of one model step after malformed results.
type Settings struct {
Scheduling run.ToolScheduling
MalformedRetries uint8
TargetResolver TargetResolver
// BeforePrepare runs each time the Run is Open and about to plan a model
// request, before the PromptBuilder reads the context (RUN-LOP-10). It
// is the application's seam for reshaping that context between steps,
// such as an in-turn checkpoint (APP-CKP-1); it commits through the
// Writer the store is bound to. An error stops the drive with no fact
// written; nil is no hook.
BeforePrepare PrepareHook
// Dispatch bounds the re-offers of an Assignment the Executor refused
// as retryable (RUN-EXE-3); the zero value selects the defaults.
Dispatch DispatchPolicy
// Watcher is where a blocking Run waits for the Outcomes it dispatched:
// one per (owner, executor), shared with the Reconciler, so waiting on
// N effects costs one settlement subscription. Its Port must be the
// Loop's Executor. Nil builds a private one over the Executor, which
// serves a single-process host; a host with a Reconciler shares its.
Watcher *effect.Watcher
}

// PrepareHook is Settings.BeforePrepare: the store is the Loop's own bound
// RunStore and input the PromptInput the plan is about to hand the
// PromptBuilder.
type PrepareHook func(ctx context.Context, store store.RunStore, input decision.Input) error

// --- EventSink: realtime observation, never authority (RUN-LOP-6) ---

type EventSink interface {
Emit(context.Context, Event) error
}

type EventDurability uint8

const (
EventProvisional EventDurability = iota
EventCommitted
)

type EventKind string

const (
EventAgentCommitted EventKind = "agent_committed"
EventModelTextDelta EventKind = "model_text_delta"
EventModelReasoningDelta EventKind = "model_reasoning_delta"
EventToolProgress EventKind = "tool_progress"
EventToolStarted EventKind = "tool_started"
EventToolCompleted EventKind = "tool_completed"
EventRunFinished EventKind = "run_finished"
// EventProgressReset voids the provisional observations of an effect
// received so far: the Worker re-dispatched it and a new Generation of
// deltas begins (RUN-EXE-12).
EventProgressReset EventKind = "progress_reset"
)

type Event struct {
// Session is the Run's Scope.
Session run.Scope
RunID run.RunID
StepID run.StepID
CallID run.CallID
// Effect names the effect a provisional observation belongs to and
// Generation its attempt under the Worker; both are zero for committed
// observations.
Effect run.EffectID
Generation int
// Sequence orders provisional observations within one stream. Committed
// observations never set it: the commit order is the authority.
Sequence uint64
Kind EventKind
Durability EventDurability
Payload json.RawMessage
// Committed is set for an EventAgentCommitted observation: the Run facts
// the accepted command produced, in stream order; nil for provisional.
Committed []run.Fact
}

type LoopDisposition uint8

const (
// LoopWaiting: no executable action; the Run waits for a response, a
// recovery, or an Outcome of an effect this Loop did not dispatch.
LoopWaiting LoopDisposition = iota
// LoopFinished: the Run is terminal; Result is set.
LoopFinished
// LoopDispatched: Advance handed at least one Assignment to the Executor
// and returned; Dispatched lists them. The Run moves again when their
// Outcomes are read by key and then settled.
LoopDispatched
// LoopDelivered: Deliver settled an Outcome and the Run is not terminal;
// the host advances it next.
LoopDelivered
// LoopDropped: Deliver found no Executing target under the Outcome's
// key -- a late Outcome of a settled or disposed attempt -- and wrote
// nothing.
LoopDropped
)

type LoopResult struct {
Disposition LoopDisposition
// Reason is execution_recovery when ExecutionRecovery is true; otherwise empty.
Reason WaitReason
// ExecutionRecovery is true when NeedsRecovery(state) is true after this
// Loop has no further executable action: a ModelStep is Executing, or a
// ToolStep has Executing calls and no Pending calls, and none of them was
// dispatched by this Loop. Under Session-level ownership this only happens
// before the owner's takeover disposition.
ExecutionRecovery bool
Result *run.RunResult
// Dispatched lists the assignments an Advance handed to the Executor.
Dispatched []AssignmentKey
}

type WaitReason string

const (
ExecutionRecovery WaitReason = "execution_recovery"
)

// DispatchPolicy is how a Loop repeats a Dispatch the Executor refused with
// effect.ErrDispatchRetryable (RUN-EXE-3): at most Retries offers inside one
// Advance, Backoff multiplied by the attempts so far between them. It is a
// deployment's setting, not part of the AgentPreset.
type DispatchPolicy struct {
Retries int
Backoff time.Duration
}

// The defaults a zero DispatchPolicy selects.
const (
DefaultDispatchRetries = 3
DefaultDispatchBackoff = 50 * time.Millisecond
)

func (p DispatchPolicy) retries() int {
if p.Retries <= 0 {
return DefaultDispatchRetries
}
return p.Retries
}

func (p DispatchPolicy) backoff() time.Duration {
if p.Backoff <= 0 {
return DefaultDispatchBackoff
}
return p.Backoff
}
88 changes: 88 additions & 0 deletions agentcore/run/loop/effectalias.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package loop

import (
"errors"

effect "github.com/felinics/twilight/agentcore/run/effect"
)

// Aliases keep the protocol types the Loop speaks source-compatible with
// run/effect; the definitions live there.
type AssignmentKind = effect.AssignmentKind

const (
AssignmentModel = effect.AssignmentModel
AssignmentTool = effect.AssignmentTool
)

// Tool outcomes belong to the process-independent effect protocol.
type ToolExecutionOutcome = effect.ToolExecutionOutcome
type ToolExecutionSucceeded = effect.ToolExecutionSucceeded
type ToolExecutionFailed = effect.ToolExecutionFailed
type ToolExecutionUnknown = effect.ToolExecutionUnknown

// ErrModelUnavailable reports a model assignment the executor cannot serve:
// the pre-start check fails with the step still Prepared and no start or
// recovery fact.
var ErrModelUnavailable = errors.New("agent: loop: executor cannot serve the model")

type AssignmentKey = effect.AssignmentKey

type ModelAssignment = effect.ModelAssignment

type ToolAssignment = effect.ToolAssignment

type Assignment = effect.Assignment

type Outcome = effect.Outcome

type OutcomeResult = effect.OutcomeResult

type ModelSucceeded = effect.ModelSucceeded

type ModelFailed = effect.ModelFailed

type Cancelled = effect.Cancelled

type Unknown = effect.Unknown

type FailureCode = effect.FailureCode

type ExecutionStatus = effect.ExecutionStatus

type AttachmentState = effect.AttachmentState

type Attachment = effect.Attachment

type Executor = effect.ExecutionPort

type Acknowledger = effect.Acknowledger

const (
ExecutionNotFound = effect.ExecutionNotFound
ExecutionAccepted = effect.ExecutionAccepted
ExecutionRunning = effect.ExecutionRunning
ExecutionCancelRequested = effect.ExecutionCancelRequested
ExecutionCompleted = effect.ExecutionCompleted
ExecutionFailed = effect.ExecutionFailed
ExecutionCancelled = effect.ExecutionCancelled
ExecutionUnknown = effect.ExecutionUnknown
ExecutionAborted = effect.ExecutionAborted

AttachmentMissing = effect.AttachmentMissing
AttachmentActive = effect.AttachmentActive
AttachmentOrphaned = effect.AttachmentOrphaned
AttachmentTerminal = effect.AttachmentTerminal
AttachmentAborted = effect.AttachmentAborted

FailureExecutor = effect.FailureExecutor
FailureFrozenValueMissing = effect.FailureFrozenValueMissing
FailureMalformedRequest = effect.FailureMalformedRequest
FailureDeadline = effect.FailureDeadline
)

var (
ErrExecutionNotFound = effect.ErrExecutionNotFound
ErrOutcomeNotReady = effect.ErrOutcomeNotReady
ErrDispatchUnknown = effect.ErrDispatchUnknown
)
75 changes: 75 additions & 0 deletions agentcore/run/loop/effectref.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package loop

import (
"context"
"errors"

run "github.com/felinics/twilight/agentcore/run"
"github.com/felinics/twilight/agentcore/run/schema"
"github.com/felinics/twilight/agentcore/run/store"
)

// effectRef is one effect of a Run as this Loop addresses it: the derived
// EffectID the start fact records, with the schema whose Identity derives the
// start, settlement and recovery CommandIDs from it (RUN-WIR-1). It holds
// nothing of the physical execution -- worker, lease and backend handle stay
// in the executor -- so any owner that reads the same state derives the same
// identities and a repeated command replays instead of granting twice.
type effectRef struct {
runID run.RunID
id run.EffectID
}

// modelEffect is the next model effect of a Prepared ModelStep: its sequence
// is the count of results the step rejected so far, so a step started again
// after a rejection requests a new effect.
func modelEffect(runID run.RunID, step *run.ModelStep) effectRef {
return effectRef{runID: runID, id: schema.Identity().DeriveEffectID(runID, step.RefValue.ID, "", step.Rejects)}
}

// toolEffect is the one tool effect of a call: a call starts at most once.
func toolEffect(runID run.RunID, stepID run.StepID, callID run.CallID) effectRef {
return effectRef{runID: runID, id: schema.Identity().DeriveEffectID(runID, stepID, callID, 0)}
}

func (e *effectRef) startID() run.CommandID { return schema.Identity().DeriveStartCommandID(e.id) }
func (e *effectRef) settlementID() run.CommandID {
return schema.Identity().DeriveSettlementCommandID(e.id)
}
func (e *effectRef) recoveryID() run.CommandID {
return schema.Identity().DeriveRecoveryCommandID(e.id)
}

// settle commits the owner settlement of an effect under its derived
// CommandID. A sentinel rejection means the effect is over (another actor
// moved the target); ownership loss is returned as is.
// When the accepted settlement terminates the Run, the terminal RunResult is
// returned: the RunStore already handed back the folded state, so the Loop
// finishes from it instead of reloading a Run the projection no longer holds.
func (l *Loop) settle(ctx context.Context, rt store.RunStore, events EventSink, e *effectRef, base run.RunPosition, cmd run.AgentCommand) (*run.RunResult, error) {
id := e.settlementID()
if _, recovering := cmd.(run.RecoverModelExecution); recovering {
id = e.recoveryID()
}
res, err := l.commit(context.WithoutCancel(ctx), rt, e.runID, id, base, cmd)
if err != nil {
if retriable(err) {
return nil, nil
}
return nil, err
}
l.emitCommitted(ctx, events, rt.Scope(), e.runID, res.Facts)
// The settlement is a Session fact: the executor may collect the
// effect's record (RUN-EXE-13). A refused or failed acknowledgement
// changes nothing here; the executor's time-based collection covers it.
if a, ok := l.Executor.(Acknowledger); ok {
_ = a.Acknowledge(ctx, AssignmentKey{Session: rt.Scope(), RunID: e.runID, Effect: e.id})
}
if res.Snapshot.State.Status.Terminal() {
return res.Snapshot.State.Result, nil
}
return nil, nil
}

// ownershipLost reports the terminal ownership error (RUN-LOP-5).
func ownershipLost(err error) bool { return errors.Is(err, store.ErrOwnershipLost) }
Loading
Loading
0