Skip to content

Runtime-enforced agent workflows (post-turn triggers) #178

Description

@louismorgner

Problem

Agent templates define multi-step workflows in their system prompts, but models routinely skip steps — especially reflection, quality checks, and state-update loops that happen after the main task is done.

Concrete example: the content-writer template.

The template (registry/agents/content-writer/agent.md) instructs the model to:

  1. Write or improve a piece based on the user's request
  2. After writing, reflect: does it match the user's voice in voice.md?
  3. After reflecting, update voice.md with any new patterns learned
  4. Save accepted pieces to writing/ for future reference

In practice, the model does step 1 and stops. Steps 2-4 — the "evolving voice" loop that makes the agent get better over time — almost never fires. This isn't a prompting problem. It's an architectural one: we're asking a single inference pass to be both executor and orchestrator.

Every other serious software system separates execution from orchestration (CI pipelines, database triggers, git hooks). Agent runtimes are the exception — they dump instructions into a system prompt and hope for the best.

Proposal: Post-Turn Triggers

Add a declarative triggers field to oc-agent.yaml that lets template authors define conditions and follow-up prompts that the runtime enforces — not the model.

Example configuration

# in oc-agent.yaml for content-writer
triggers:
  - name: voice-review
    when:
      file_written: "writing/*.md"
    prompt: |
      You just saved a new piece to writing/. Before moving on:
      1. Re-read voice.md
      2. Does the piece match the user's voice? Be specific about what matches and what doesn't.
      3. If it doesn't match, revise the piece now.
      4. Update voice.md if you learned any new patterns about how the user writes.

How it works

Intervention point: internal/runtime/native_runner.go, lines 661-662 — the moment the model decides it's "done" (returns no tool calls):

// CURRENT behavior:
if len(msg.ToolCalls) == 0 {
    return nil // trust the model's decision to stop
}

// NEW behavior:
if len(msg.ToolCalls) == 0 {
    if trigger := evaluateTriggers(state, toolCtx.Config.Triggers, firedTriggers); trigger != nil {
        firedTriggers[trigger.Name] = true
        triggerMsg := Message{Role: "user", Content: trigger.Prompt}
        state.Messages = append(state.Messages, triggerMsg)
        state.Transcript = append(state.Transcript, triggerMsg)
        continue // re-enter the agent loop
    }
    return nil
}

Key detail: firedTriggers is a map[string]bool scoped to the current runNativePrompt call. Each trigger fires at most once per user prompt, preventing infinite loops (trigger fires → model writes again → trigger fires again).

Trigger conditions

The WorkingSet (in internal/runtime/context_view.go) already tracks everything we need:

Condition Matches against Example
file_written: "pattern" WorkingSet.FilesWritten "writing/*.md"
file_edited: "pattern" WorkingSet.FilesEdited "voice.md"
tool_used: "ToolName" Tool call history in turn "Bash"

Patterns use the same glob matching we already use for context syncing.

Important: conditions are evaluated against changes since the last trigger evaluation, not the cumulative working set. This means a trigger only fires when a new matching file appears during the current turn, not because a file was written 10 turns ago.

What changes

File Change
internal/agent/agent.go Add Triggers []TriggerConfig to AgentConfig struct
internal/runtime/session_config.go Add Triggers []TurnTrigger to SessionConfig, wire from agent config
internal/runtime/native_runner.go Add trigger evaluation at turn boundary (~20 lines in runNativeLoop)
internal/runtime/triggers.go New file: TurnTrigger struct, evaluateTriggers() function, glob matching against WorkingSet
registry/agents/content-writer/oc-agent.yaml Add triggers: block as first consumer

Estimated scope: ~150 lines of Go + tests.

Walk-through: content-writer with triggers

Without triggers (current):

User: "Turn this into a LinkedIn post: [rough draft]"
  → Model writes the post, saves to writing/2026-03-29-linkedin-ai-tooling.md
  → Model stops. voice.md never updated. No voice review happened.
  → Next session: voice.md is still the generic bootstrap version.

With triggers:

User: "Turn this into a LinkedIn post: [rough draft]"
  → Model writes the post, saves to writing/2026-03-29-linkedin-ai-tooling.md
  → Model stops (no more tool calls)
  → Runtime checks triggers: file_written "writing/*.md" matches!
  → Runtime injects: "You just saved a new piece. Review against voice.md..."
  → Model re-reads voice.md, reflects, revises the piece, updates voice.md
  → Model stops again. Trigger already fired (in firedTriggers set). 
  → Runtime returns. Turn complete.
  → Next session: voice.md has been refined. Agent writes better.

Future evolution

This is the foundation for more advanced patterns:

  • Chained triggers: trigger B fires only after trigger A completes (e.g., voice-evolve fires after voice-review)
  • Obligation checking: verify the model actually did the work (e.g., check WorkingSet.FilesRead includes voice.md after the trigger prompt) using heuristics or a lightweight small_model call
  • Tool-level hooks: fire triggers immediately after specific tool calls, not just at turn boundaries — useful for inline feedback
  • Trigger metrics: track how often triggers fire vs. how often the model would have skipped the step — measures model reliability and template quality

Why this matters

No existing agent runtime does this. Claude Code, Cursor, Windsurf, Devin — they all treat the model as a monolithic executor where the prompt is the only control mechanism. This is the difference between:

  • Prompt engineering: "Please remember to always review your work"
  • Runtime enforcement: The runtime won't let you finish until the review runs

This positions toc-native as the runtime that guarantees agent workflow quality, not just agent execution. Template authors can encode quality standards that the runtime enforces, regardless of model discipline.

Metadata

Metadata

Assignees

No one assigned

    Labels

    architectureArchitectural decisions and changesenhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions