Skip to main content

SLOP Agents

LLM as Service, Not Controller

The web, db, and math service calls in the patterns below (web.search, db.query, math.eval, ...) are illustrative — they are not built into SLOP. Only llm is registered by default; a host application registers any other service with Runtime.RegisterService (see the Built-in Functions page, section 4).


1. The Paradigm Shift

Traditional Agent Frameworks (LangGraph, etc.)

┌─────────────────────────────────────┐
│ LLM │ ← LLM controls everything
│ "What should I do next?" │
│ "Should I continue or stop?" │
│ "Which tool should I use?" │
└─────────────────┬───────────────────┘
│ (implicit decisions)

┌─────────────────────────────────────┐
│ Graph/State Machine │
│ - Hidden state │
│ - Magic routing │
│ - Unbounded loops │
└─────────────────────────────────────┘

SLOP Approach

┌─────────────────────────────────────┐
│ Script │ ← Code controls everything
│ - Explicit flow │
│ - Visible state │
│ - Bounded loops │
└─────────────────┬───────────────────┘
│ (explicit calls)

┌─────────────────────────────────────┐
│ LLM (as service) │
│ - Structured input │
│ - Structured output │
│ - Just another tool │
└─────────────────────────────────────┘

2. Core Pattern

# The fundamental pattern: LLM advises, code decides

# 1. Get structured advice from LLM
decision = llm.call(
prompt: "Given {context}, what should we do?",
schema: {action: enum(option1, option2, option3), reason: string}
)

# 2. Code decides what to do with the advice
match decision.action:
option1 -> do_thing_one()
option2 -> do_thing_two()
option3 -> do_thing_three()

Key properties:

  • LLM output is structured (not free text)
  • Code validates the response (schema enforced)
  • Code controls what happens next
  • Flow is visible in the script

3. Agent Patterns

3.1 ReAct Agent

# ReAct: Reason + Act in a loop

task = input.task
context = []

for step in range(10): # BOUNDED - max 10 steps
# REASON: Ask LLM what to do
thought = llm.call(
prompt: """
Task: {task}
Previous steps: {context}

What should I do next?
""",
schema: {
reasoning: string,
action: enum(search, calculate, lookup, answer),
action_input: string
}
)

# Check if done
if thought.action == "answer":
emit(answer: thought.action_input, steps: len(context))
stop

# ACT: Execute the chosen action
result = match thought.action:
search -> web.search(thought.action_input, limit: 5)
calculate -> math.eval(thought.action_input)
lookup -> db.query(thought.action_input)

# OBSERVE: Record result for next iteration
context.append({
thought: thought.reasoning,
action: thought.action,
input: thought.action_input,
result: result
})

# Max steps reached
emit(
answer: none,
error: "Max steps reached",
partial_context: context
)

3.2 Plan-and-Execute

# Plan first, then execute deterministically

task = input.task

# PLAN: One LLM call to create the plan
plan = llm.call(
prompt: "Create a step-by-step plan for: {task}",
schema: {
steps: list({
description: string,
tool: enum(search, fetch, analyze, summarize),
expected_output: string
})
}
)

# EXECUTE: Deterministic execution of plan
results = []
for i, step in enumerate(plan.steps) with limit(20):
log.info("Step {i+1}: {step.description}")

result = match step.tool:
search -> web.search(step.description, limit: 10)
fetch -> web.fetch(step.description)
analyze -> llm.call(
prompt: "Analyze: {step.description}\nData: {results}",
schema: {analysis: string, key_points: list(string)}
)
summarize -> llm.call(
prompt: "Summarize: {results}",
schema: {summary: string}
)

results.append({step: i, description: step.description, result: result})

# Optional: Check if we need to replan
if result.error:
replan = llm.call(
prompt: "Step failed: {step}. Error: {result.error}. Revise plan.",
schema: {revised_steps: list(...), should_continue: bool}
)
if not replan.should_continue:
break
plan.steps = replan.revised_steps

# SYNTHESIZE: Final answer
answer = llm.call(
prompt: "Synthesize final answer from: {results}",
schema: {answer: string, confidence: float}
)

emit(answer: answer.answer, confidence: answer.confidence, steps: results)

3.3 Tool Selection Agent

# Simple tool selection pattern

query = input.query

# Available tools with descriptions
tools = {
search: "Search the web for information",
calculate: "Perform mathematical calculations",
lookup: "Look up data in the database",
fetch: "Fetch a specific URL",
none: "No tool needed - answer directly"
}

# Ask LLM which tool to use
selection = llm.call(
prompt: """
Query: {query}

Available tools:
{tools | format_list}

Which tool should be used?
""",
schema: {
tool: enum(search, calculate, lookup, fetch, none),
tool_input: string,
reasoning: string
}
)

# Execute selected tool
result = match selection.tool:
search -> web.search(selection.tool_input)
calculate -> math.eval(selection.tool_input)
lookup -> db.query(selection.tool_input)
fetch -> web.fetch(selection.tool_input)
none -> {direct: true}

# Generate final answer
if result.direct:
answer = llm.call(
prompt: "Answer directly: {query}",
schema: {answer: string}
)
else:
answer = llm.call(
prompt: "Answer {query} using this data: {result}",
schema: {answer: string, sources: list(string)}
)

emit(answer)

3.4 Multi-Agent Collaboration

There is no agent.run() or other built-in mechanism for one SLOP script to invoke another as a sub-agent — no agent service or keyword exists anywhere in the runtime. Composing multiple "agent" roles today means composing multiple llm.call() steps with different prompts/schemas in the same script, or wiring separate scripts together at the host level:

# Multiple LLM roles within a single script

task = input.task

# "Researcher" role
research = llm.call(
prompt: "Research: {task}",
schema: {findings: string, sources: list(string)}
)

# "Critic" role — reviews the research
critique = llm.call(
prompt: "Critique this research against accuracy, completeness, and relevance: {research.findings}",
schema: {passed: bool, feedback: string}
)

# Iterate if needed (BOUNDED)
for revision in range(3) with limit(3):
if critique.passed:
break

research = llm.call(
prompt: "Revise based on feedback: {critique.feedback}. Previous: {research.findings}",
schema: {findings: string, sources: list(string)}
)
critique = llm.call(
prompt: "Critique this research: {research.findings}",
schema: {passed: bool, feedback: string}
)

# "Writer" role — produces final output
final = llm.call(
prompt: "Write the final answer from: {research.findings}",
schema: {output: string}
)

emit(final.output)

3.5 Autonomous Agent with Guardrails

# Agent with explicit safety checks

task = input.task
max_cost = input.max_cost or 5.00
forbidden_actions = ["delete", "send_email", "purchase"]

context = []
total_cost = 0.0

for step in range(20):
# Get next action
action = llm.call(
prompt: "Task: {task}\nContext: {context}\nWhat next?",
schema: {
action: string,
tool: enum(search, analyze, create, done),
params: object
}
)

# GUARDRAIL: Check forbidden actions
if any(f in action.action.lower() for f in forbidden_actions):
log.warn("Blocked forbidden action: {action.action}")
context.append({blocked: action.action, reason: "forbidden"})
continue

# GUARDRAIL: Check cost
estimated_cost = estimate_cost(action)
if total_cost + estimated_cost > max_cost:
log.warn("Cost limit would be exceeded")
emit(
partial: context,
stopped: "cost_limit",
spent: total_cost
)
stop

# GUARDRAIL: Require confirmation for sensitive actions
if action.tool == "create":
confirmation = llm.call(
prompt: "Confirm this action is safe: {action}",
schema: {safe: bool, concerns: list(string)}
)
if not confirmation.safe:
log.warn("Action flagged as unsafe: {confirmation.concerns}")
context.append({skipped: action, concerns: confirmation.concerns})
continue

# Execute action
if action.tool == "done":
emit(result: action.params.result, steps: len(context), cost: total_cost)
stop

result = execute_tool(action.tool, action.params)
total_cost += result.cost
context.append({action: action, result: result})

emit(error: "max_steps", partial: context)

4. LLM Call Patterns

4.1 Structured Extraction

# Extract structured data from unstructured text
text = input.text

extracted = llm.call(
prompt: "Extract information from:\n{text}",
schema: {
people: list({name: string, role: string}),
organizations: list({name: string, type: string}),
dates: list({date: string, event: string}),
key_facts: list(string)
}
)

emit(extracted)

4.2 Classification

# Classify with confidence
item = input.item

classification = llm.call(
prompt: "Classify this item:\n{item}",
schema: {
category: enum(bug, feature, question, other),
confidence: float,
reasoning: string,
subcategory: string?
}
)

if classification.confidence < 0.7:
# Low confidence - flag for review
emit(needs_review: true, classification: classification)
else:
emit(classification)

4.3 Validation/Critique

# Validate content
content = input.content
criteria = input.criteria

validation = llm.call(
prompt: """
Validate this content against criteria:

Content: {content}

Criteria: {criteria}
""",
schema: {
valid: bool,
issues: list({
criterion: string,
passed: bool,
feedback: string
}),
overall_score: float,
suggestions: list(string)
}
)

emit(validation)

4.4 Decomposition

# Break complex task into subtasks
task = input.task

decomposition = llm.call(
prompt: "Break this task into subtasks:\n{task}",
schema: {
subtasks: list({
id: int,
description: string,
dependencies: list(int), # IDs of prerequisite subtasks
estimated_complexity: enum(low, medium, high)
}),
execution_order: list(int)
}
)

# Execute in order
results = {}
for task_id in decomposition.execution_order:
subtask = decomposition.subtasks | find(t -> t.id == task_id)

# Gather dependency results
dep_results = [results[d] for d in subtask.dependencies]

result = execute_subtask(subtask, dep_results)
results[task_id] = result

emit(results)

5. Error Handling

5.1 Retry with Feedback

for attempt in range(3):
result = llm.call(
prompt: "{task}\n{feedback if attempt > 0 else ''}",
schema: expected_schema
)

validation = validate(result)
if validation.ok:
emit(result)
stop

feedback = "Previous attempt failed: {validation.errors}. Please fix."

emit(error: "Failed after 3 attempts", last_result: result)

5.2 Fallback Chain

# Try multiple approaches
approaches = [
{model: "claude-sonnet", temp: 0.0},
{model: "claude-sonnet", temp: 0.5},
{model: "claude-opus", temp: 0.0}
]

for approach in approaches:
try:
result = llm.call(
prompt: task,
schema: schema,
model: approach.model,
temperature: approach.temp
)
if validate(result):
emit(result)
stop
catch error:
log.warn("Approach failed: {approach}, error: {error}")
continue

emit(error: "All approaches failed")

5.3 Graceful Degradation

# Full result with fallback to partial
task = input.task

# Try full analysis
try:
full = llm.call(
prompt: "Full analysis of: {task}",
schema: {
summary: string,
details: list({...}),
recommendations: list({...}),
confidence: float
},
max_tokens: 4000
)
emit(full)
stop
catch TokenLimitError:
log.warn("Full analysis too large, falling back to summary")

# Fallback to summary only
summary = llm.call(
prompt: "Brief summary of: {task}",
schema: {summary: string, key_points: list(string)},
max_tokens: 1000
)
emit(summary: summary, degraded: true)

6. Comparison with LangGraph

LangGraph Version

# 40+ lines of boilerplate, hidden flow

from langgraph.graph import StateGraph, END
from typing import TypedDict, Annotated

class AgentState(TypedDict):
messages: list
next_action: str
iteration: int

def analyze_node(state: AgentState):
# ... hidden logic
return {"messages": [...], "next_action": "search"}

def search_node(state: AgentState):
# ... hidden logic
return {"messages": [...]}

def should_continue(state: AgentState):
if state["iteration"] > 10:
return "end"
if state["next_action"] == "done":
return "end"
return "continue"

# Build graph
workflow = StateGraph(AgentState)
workflow.add_node("analyze", analyze_node)
workflow.add_node("search", search_node)
workflow.add_conditional_edges(
"analyze",
should_continue,
{"continue": "search", "end": END}
)
workflow.add_edge("search", "analyze")
workflow.set_entry_point("analyze")

app = workflow.compile()
result = app.invoke({"messages": [task], "iteration": 0})
# What actually happened? Good luck figuring it out.

SLOP Version

# 25 lines, completely visible flow

task = input.task
context = []

for step in range(10):
analysis = llm.call(
prompt: "Task: {task}\nContext: {context}\nWhat next?",
schema: {action: enum(search, done), query: string}
)

if analysis.action == "done":
emit(answer: analysis.query, steps: len(context))
stop

results = web.search(analysis.query, limit: 5)
context.append({query: analysis.query, results: results})

emit(error: "max_steps", partial: context)
# Every step is visible. Flow is obvious. Debugging is trivial.

7. Debugging

There is no --trace or --step flag on slop run, and no interactive debugger. What exists for inspecting a script before/after running it:

$ slop check agent.slop # Parse + termination + bounds, see Safety page
$ slop plan agent.slop # Static resource-bounds report, no step trace

For runtime visibility, use log_info/log_debug calls inside the script itself — every call is written to stderr as it executes — or print() for ad hoc values.


8. Summary

AspectLangGraphSLOP
Who controls flowLLM (implicit)Code (explicit)
State visibilityHidden in dictVariables you can see
Loop boundsHope it stopsrange(10) - guaranteed
Debugging"Why did it go there?"Read the code
Error handlingFramework magicNormal try/catch
Cost controlPrayCounted and limited
Learning curveFramework conceptsJust Python-ish

SLOP agents are just scripts. You can read them, debug them, bound them, and understand them.