kelos-dev / kelos-dev/kelos

API: Add taskCompletion trigger source and structured outputs for multi-step agent workflows

Open
#283 2 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

generated-by-kelos kelos/needs-input kind/api needs-actor needs-triage priority/important-longterm
Dominant language
Go
Stars
331
Forks
40
Avg merge
1d 21h
Merged PRs (30d)
70

Description

🤖 Axon Agent @gjkim42

Summary

Axon's current primitives (Task, Workspace, TaskSpawner) handle single-step autonomous workflows well, but many real-world agent workflows are multi-step: one agent's output feeds into the next agent's input. Today, this requires external orchestration or encoding the entire multi-step workflow into a single monolithic prompt. This proposal adds a taskCompletion trigger source to TaskSpawner and structured key-value outputs to Task, enabling task chaining using existing primitives — no new CRDs needed.

Problem

1. No way to chain Tasks together

The When struct only supports two triggers (api/v1alpha1/taskspawner_types.go:20-29):

type When struct {
    GitHubIssues *GitHubIssues `json:"githubIssues,omitempty"`
    Cron         *Cron         `json:"cron,omitempty"`
}

There's no way to say "when Task X finishes, start Task Y with X's results." Users who need multi-step workflows must either:

  • Cram everything into one giant prompt (brittle, expensive, hard to debug)
  • Build external orchestration outside of Kubernetes (defeats the purpose of Axon)
  • Manually create follow-up Tasks
2. Task outputs are unstructured

Task outputs (task_types.go:146) are []string — raw lines captured between markers:

Outputs []string `json:"outputs,omitempty"`

The output parser (internal/controller/output_parser.go) just splits lines between ---AXON_OUTPUTS_START--- and ---AXON_OUTPUTS_END---. There's no way to capture named values that a downstream Task could reference. For example, an agent that creates a PR produces output like https://github.com/org/repo/pull/123 and branch: fix-bug, but a downstream agent can't easily extract and use these values.

Proposed API Changes

Part 1: Structured Key-Value Outputs on Task

Extend TaskStatus with a structured output map alongside the existing outputs list:

type TaskStatus struct {
    // ... existing fields ...

    // Outputs contains raw output lines produced by the agent.
    // +optional
    Outputs []string `json:"outputs,omitempty"`

    // OutputMap contains structured key-value outputs produced by the agent.
    // Parsed from lines in the format "key: value" between output markers.
    // +optional
    OutputMap map[string]string `json:"outputMap,omitempty"`
}

The output parser already captures lines like branch: fix-bug — it just needs to also parse key: value pairs into OutputMap. Lines that don't match the key: value pattern (like bare URLs) continue to go into Outputs as before.

Changes to internal/controller/output_parser.go:

type ParsedOutputs struct {
    Lines     []string
    KeyValues map[string]string
}

func ParseOutputs(logData string) *ParsedOutputs {
    // ... existing marker extraction ...
    result := &ParsedOutputs{KeyValues: make(map[string]string)}
    for _, line := range lines {
        if k, v, ok := parseKeyValue(line); ok {
            result.KeyValues[k] = v
        }
        result.Lines = append(result.Lines, line)
    }
    return result
}

This is fully backward compatible — existing agents that emit branch: my-branch will automatically get structured output without any changes.

Part 2: taskCompletion Trigger Source on TaskSpawner

Add a new trigger type to When:

type When struct {
    GitHubIssues   *GitHubIssues   `json:"githubIssues,omitempty"`
    Cron           *Cron           `json:"cron,omitempty"`
    TaskCompletion *TaskCompletion `json:"taskCompletion,omitempty"` // NEW
}

type TaskCompletion struct {
    // Selector matches completed Tasks by label. When a Task matching
    // this selector reaches a terminal phase, the spawner creates a
    // new downstream Task.
    // +kubebuilder:validation:Required
    Selector map[string]string `json:"selector"`

    // Phase filters which terminal phase triggers spawning.
    // Defaults to "Succeeded". Set to "Any" to trigger on both
    // Succeeded and Failed.
    // +kubebuilder:validation:Enum=Succeeded;Failed;Any
    // +kubebuilder:default=Succeeded
    // +optional
    Phase string `json:"phase,omitempty"`
}

The spawner implementation would use the existing Kubernetes watch API (not polling) to watch for Task status transitions — this is both efficient and low-latency:

type TaskCompletionSource struct {
    Client    client.Client
    Namespace string
    Selector  map[string]string
    Phase     string
    // tracks already-processed Tasks to avoid duplicates
    Seen      map[string]bool
}

func (s *TaskCompletionSource) Discover(ctx context.Context) ([]WorkItem, error) {
    var tasks axonv1alpha1.TaskList
    err := s.Client.List(ctx, &tasks,
        client.InNamespace(s.Namespace),
        client.MatchingLabels(s.Selector),
    )
    // Filter to terminal tasks not yet seen...
    // Convert to WorkItems with outputs available as template variables
}
Part 3: Template Variables for Completed Tasks

New template variables available in promptTemplate when using taskCompletion:

Variable Description
{{.TaskName}} Name of the completed upstream Task
{{.TaskPhase}} Phase of the completed Task (Succeeded or Failed)
{{.Outputs}} Raw output lines (joined with newlines)
{{.OutputMap}} Structured key-value map (access as {{index .OutputMap "branch"}})
{{.Prompt}} The prompt that was given to the upstream Task
{{.Labels}} Labels from the completed Task

Use Cases

1. Code + Review Pipeline

Agent A writes code, then Agent B (a different model or specialized reviewer) reviews it:

# Step 1: Code writer
apiVersion: axon.io/v1alpha1
kind: TaskSpawner
metadata:
  name: code-writer
spec:
  when:
    githubIssues:
      labels: [bug]
  taskTemplate:
    type: claude-code
    workspaceRef:
      name: my-repo
    credentials:
      type: oauth
      secretRef:
        name: claude-creds
    promptTemplate: |
      Fix issue #{{.Number}}: {{.Title}}
      {{.Body}}
      Create a PR with the fix.
    podOverrides:
      env:
        - name: AXON_STEP
          value: "code-writer"
---
# Step 2: Auto-reviewer triggered by code-writer completion
apiVersion: axon.io/v1alpha1
kind: TaskSpawner
metadata:
  name: code-reviewer
spec:
  when:
    taskCompletion:
      selector:
        axon.io/taskspawner: code-writer
      phase: Succeeded
  taskTemplate:
    type: claude-code
    model: opus
    workspaceRef:
      name: my-repo
    credentials:
      type: oauth
      secretRef:
        name: claude-creds
    promptTemplate: |
      Review the PR created by the upstream agent.
      PR URL: {{index .OutputMap "pr_url"}}
      Branch: {{index .OutputMap "branch"}}

      Check for:
      - Security vulnerabilities
      - Performance issues
      - Test coverage
      Leave a thorough review on the PR.
2. Test-Fix Loop

An agent runs tests; if they fail, a fix agent is spawned:

# Step 1: Test runner (cron)
apiVersion: axon.io/v1alpha1
kind: TaskSpawner
metadata:
  name: nightly-tests
spec:
  when:
    cron:
      schedule: "0 2 * * *"
  taskTemplate:
    type: claude-code
    workspaceRef:
      name: my-repo
    credentials:
      type: api-key
      secretRef:
        name: anthropic-key
    promptTemplate: |
      Run `make test` and report results.
      Output the test results summary as structured outputs:
        status: pass/fail
        failures: <count>
        summary: <one-line summary>
---
# Step 2: Fix agent, only when tests fail
apiVersion: axon.io/v1alpha1
kind: TaskSpawner
metadata:
  name: test-fixer
spec:
  when:
    taskCompletion:
      selector:
        axon.io/taskspawner: nightly-tests
      phase: Any  # trigger on both success and failure
  taskTemplate:
    type: claude-code
    model: opus
    workspaceRef:
      name: my-repo
    credentials:
      type: oauth
      secretRef:
        name: claude-creds
    promptTemplate: |
      {{if eq .TaskPhase "Failed"}}
      The nightly test run failed. Here are the results:
      {{.Outputs}}
      Please investigate and fix the failing tests, then open a PR.
      {{else}}
      Tests passed. No action needed.
      {{end}}
3. Multi-Agent Specialization

Different agents handle different aspects of the same issue — one for backend, one for frontend:

# Backend agent
apiVersion: axon.io/v1alpha1
kind: TaskSpawner
metadata:
  name: backend-agent
spec:
  when:
    githubIssues:
      labels: [fullstack]
  taskTemplate:
    type: claude-code
    workspaceRef:
      name: my-repo
    credentials:
      type: oauth
      secretRef:
        name: claude-creds
    promptTemplate: |
      Implement the backend changes for issue #{{.Number}}: {{.Title}}
      {{.Body}}
      Only modify files in cmd/, internal/, api/.
      Output the branch name and a summary of API changes.
---
# Frontend agent, triggered when backend is done
apiVersion: axon.io/v1alpha1
kind: TaskSpawner
metadata:
  name: frontend-agent
spec:
  when:
    taskCompletion:
      selector:
        axon.io/taskspawner: backend-agent
      phase: Succeeded
  taskTemplate:
    type: claude-code
    workspaceRef:
      name: my-repo
    credentials:
      type: oauth
      secretRef:
        name: claude-creds
    promptTemplate: |
      The backend agent completed work on branch: {{index .OutputMap "branch"}}
      API changes summary: {{index .OutputMap "summary"}}

      Now implement the frontend changes to use the new API.
      Work on top of the same branch.

Implementation Plan

This can be implemented incrementally:

Phase 1: Structured Outputs (small, self-contained)
  • Extend ParseOutputs to return ParsedOutputs with both lines and key-value map
  • Add OutputMap to TaskStatus
  • Update task_controller.go to populate OutputMap
  • Update CRD generation (make update)
  • Estimated scope: ~50 lines of code changes + tests
Phase 2: taskCompletion Trigger Source
  • Add TaskCompletion to When struct
  • Implement TaskCompletionSource in internal/source/
  • Extend spawner to handle taskCompletion (use Kubernetes List with label selector in poll loop, or informer/watch for lower latency)
  • Add new template variables for completed task data
  • Add XValidation rule for mutual exclusivity
  • Estimated scope: ~200 lines + tests
Phase 3: CLI Support
  • axon create taskspawner --source task-completion --selector "axon.io/taskspawner=code-writer"
  • Add TaskCompletion display to axon get taskspawner

Design Decisions

Why not a new TaskPipeline CRD?

A new CRD would provide a more "complete" solution for DAG-based workflows, but:

  1. Composability: TaskSpawner triggers are composable — you can chain any combination of GitHub issues → task completion → cron without a rigid pipeline abstraction
  2. Simplicity: A trigger on When is a much smaller API surface than a full pipeline CRD with step definitions, conditional branching, and failure handling
  3. Incremental adoption: Users can add one taskCompletion spawner at a time rather than rewriting their workflow into a pipeline spec
  4. Consistency: The TaskSpawner already handles "watch something → create Tasks" — task completion is just another thing to watch

If complex DAG workflows become common, a TaskPipeline CRD could be introduced later as syntactic sugar that generates the underlying TaskSpawners.

Why label selectors (not direct Task references)?

Label selectors (selector: {axon.io/taskspawner: code-writer}) are more flexible than pointing to a specific upstream Task name:

  • TaskSpawner already labels its Tasks with axon.io/taskspawner: <name> (see cmd/axon-spawner/main.go:180)
  • Users can add custom labels for more sophisticated routing
  • Works naturally with the existing Kubernetes pattern
Polling vs. Watch for taskCompletion

The spawner already uses a poll loop. For task completion:

  • Polling is simplest (list Tasks with label selector each cycle) and consistent with existing architecture
  • Watch/informer provides lower latency but adds complexity to the spawner
  • Recommend starting with polling and migrating to watch later (the Source interface abstracts this)

Backward Compatibility

  • Adding TaskCompletion *TaskCompletion to When is a non-breaking additive change
  • Adding OutputMap map[string]string to TaskStatus is a non-breaking additive change
  • Existing output lines like branch: my-branch will be parsed into both Outputs (as before) and OutputMap (new)
  • No changes to existing agent images required — they already emit key: value lines

References

  • When struct: api/v1alpha1/taskspawner_types.go:20-29
  • TaskStatus.Outputs: api/v1alpha1/task_types.go:146
  • Output parser: internal/controller/output_parser.go
  • Spawner task creation with labels: cmd/axon-spawner/main.go:176-193
  • Source interface: internal/source/source.go:20-22
  • Prompt rendering: internal/source/prompt.go
  • Related: #268 (webhook trigger — different scope, complementary)

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with api/v1alpha1/taskspawner_types.go and task_types.go, then read internal/controller/output_parser.go and task_controller.go for the existing output flow. Review internal/source/ and cmd/axon-spawner/main.go for trigger discovery and task labels. Done means structured outputs, taskCompletion triggering, template variables, CRD updates, CLI support, and tests cover the proposed phases.

Written by the indexing model from the issue text.

Assessment

Tech stack
go, kubernetes
Domain
ai, backend, infrastructure
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Stale
Clarity
Mostly clear
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.