Skip to content

[FEATURE] Uplift Plugin System to support richer agent loop extensions (interrupts, custom stop reasons, resume) #808

@jsamuel1

Description

@jsamuel1

Problem Statement

The current Plugin interface (Plugin.initAgent + hook callbacks) is powerful for observing and lightly modifying agent behavior — cancelling tool calls, retrying, logging, adding tools. However, it cannot express features that need to alter the agent loop's control flow, such as human-in-the-loop interrupts.

PR #586 implements a full interrupt system, but it requires ~400 lines of changes directly inside agent.ts because the plugin system lacks the extension points needed to build it externally. Specifically, the interrupt system needs to:

  1. Halt the agent loop mid-execution with a custom stop reason and payload
  2. Store per-invocation state on the agent (interrupt tracking, partial tool results)
  3. Resume execution from where it left off (skip model call, re-execute only pending tools)
  4. Intercept tool execution flow (skip already-completed tools on resume)

None of these are possible through the current Plugin interface, which only offers initAgent(agent) and getTools().

Proposed Solution

Extend the Plugin interface and agent loop with richer extension points. There are several options, roughly ordered from minimal to comprehensive:

Option A: Minimal — HaltSignal + Plugin State (smallest change)

Add two things:

1. Plugin-scoped state on the agent

// On LocalAgent interface
getPluginState<T>(pluginName: string): T
setPluginState<T>(pluginName: string, state: T): void

Plugins currently resort to Object.defineProperty hacks or closures to store state. A sanctioned API would make this clean and discoverable.

2. HaltSignal — a way for hooks to stop the loop

interface HaltSignal {
  stopReason: string                 // custom stop reason (e.g. 'interrupt')
  data: Record<string, unknown>      // plugin-specific payload for AgentResult
  resumable: boolean                 // hint: can the caller resume?
}

Hook callbacks could throw or return a HaltSignal to tell the agent loop "stop here, return this to the caller." The agent loop would catch it generically and produce an AgentResult with the custom stop reason.

This alone doesn't solve resume, but it's the minimum viable change that unblocks a class of plugins that need to pause execution.

Option B: Add Resume Path

Build on Option A with a generic resume mechanism:

// On Agent
resume(pluginName: string, data: unknown): Promise<AgentResult>

When called, the agent loop would:

  1. Look up the plugin's saved state
  2. Let the plugin decide how to re-enter the loop (skip model call, restore partial results, etc.)
  3. Continue execution

This requires a plugin lifecycle hook for resume:

interface Plugin {
  // ... existing
  onResume?(agent: LocalAgent, data: unknown): ResumeDirective
}

interface ResumeDirective {
  skipModelCall: boolean
  restoreMessage?: Message        // the assistant message to re-execute tools for
  completedToolResults?: ToolResultBlock[]  // tools already done
}

Option C: Comprehensive — Tool Execution Middleware

The most flexible approach. Let plugins wrap the tool execution pipeline:

interface Plugin {
  // ... existing
  wrapToolExecution?(
    next: (toolUse: ToolUseBlock) => AsyncGenerator<AgentStreamEvent, ToolResultBlock, undefined>,
    context: { agent: LocalAgent; toolUse: ToolUseBlock }
  ): AsyncGenerator<AgentStreamEvent, ToolResultBlock | null, undefined>

  afterToolsExecution?(context: {
    agent: LocalAgent
    toolResults: ToolResultBlock[]
    modelMessage: Message
  }): HaltSignal | undefined
}

This is the most powerful but also the most complex to implement and maintain. It would enable not just interrupts but any plugin that needs to modify how tools are executed (rate limiting, caching, parallel execution strategies, etc.).

Option D: Keep interrupts in core, don't plugin-ify

The Python SDK treats interrupt as a first-class agent loop concept rather than a plugin. Given how deeply interrupts touch the loop's control flow (halt, state save, resume, tool skip), it may be more pragmatic to keep it in core and accept that some features are inherently part of the agent loop, not extensions to it.

The argument for this: the agent loop is already the orchestrator for model calls, tool execution, cancellation, structured output, and conversation management. Interrupts are arguably in the same category — a core orchestration concern, not an add-on.

Use Case

The primary motivating use case is human-in-the-loop workflows — pausing agent execution before sensitive tool calls to get human approval, then resuming. See #586 for the full implementation.

Beyond interrupts, richer plugin extension points would enable:

  • Custom approval gates — pause for async approval from external systems
  • Checkpoint/restore — save agent loop state for long-running workflows
  • Tool execution strategies — parallel tool execution, caching, rate limiting
  • Custom stop conditions — domain-specific reasons to halt the loop

Alternative Solutions

The current interrupt PR (#586) works by modifying agent.ts directly. This is functional but means:

  • Every similar feature requires core changes
  • The agent loop grows in complexity with each new control flow path
  • External library users can't build equivalent features

The plugin approach trades upfront framework complexity for long-term extensibility.

Additional Context

  • Interrupt PR: feat(agent): add human-in-the-loop interrupt system #586
  • The Python Strands SDK has interrupts as a core concept — worth considering parity
  • The current Plugin interface was designed for hooks + tools, which covers observation and light modification but not control flow changes
  • ConversationManager is already a plugin that extends the loop (via reduce) — interrupts would be a similar pattern but for tool execution rather than message management
  • Looking for the Strands team's perspective on which option (or combination) makes sense for the SDK's direction

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions