Luma public docs
Türkçe

Runtime

Agents & Orchestration

An agent is the unit that behavior is packaged into in Luma: a model profile, a role, the list of permitted tools, a memory policy and one or more typed methods. An agent method can call an LLM, run a deterministic computation, or mix both in the same flow.

Agent anatomy

luma
agent Reporter(rule: string) {          // constructor parameter
  model: Primary                  // model profile
  role: "{rule}"                 // system role (interpolated)
  temperature: 0
  retry: 2
  tools: [calculate]              // tools exposed to the LLM
  contexts: [document]               // reachable contexts
  uses researcher: Researcher("Extract two findings from the document.")

  fn report(topic: string, document: string) -> Report {
    research = researcher.research(topic, document)
    ask "Turn the research into a report.\n\nRESEARCH:\n{research}" -> Report
  }
}

reporter = Reporter("Write neutrally and stay faithful to the document.")
result   = reporter.report(topic, document)
PartDescription
Constructor parametersagent Name(p: string) — supplied while the instance is created, validated by type, and visible in every method.
Configuration keysWritten as key: value, before the methods. The order does not matter.
usesA dependent-agent declaration; called inside a method with name.method(...).
fn methodsTyped input and output. An agent may contain more than one method.

Creating and calling an instance

luma
first  = Chatbot()          // empty parentheses when there is no constructor parameter
second = Analyst("rule")    // the argument count must match exactly

x = first.answer(question)  // method call — a top-level operation

You can create more than one instance of the same agent; under memory_scope: "instance" each instance keeps its own history.

Method types

Whether a method calls the LLM is visible from its body. There are three patterns:

1 · Deterministic method

luma
agent Calculator {
  fn add(a: float, b: float) -> Result {
    total = a + b
    return { value: total }
  }
}

There is no ask at all, and it ends with return. No request goes to the gateway and no .env is needed. The return value is still validated against the schema.

2 · LLM method (terminal ask)

luma
agent Analyst {
  fn analyze(text: string) -> Analysis {
    ask "Analyze the text: {text}" -> Analysis
  }
}

The body ends with a single ask and contains no return. The target schema of the ask must be exactly the same as the method's return type (array-ness included: if it is -> Analysis[] then ask … -> Analysis[]).

ask and return cannot be used together

If a terminal ask is written together with a return, the parser stops: Agent method f cannot use ask and return together. If you want a mixed flow, assign the ask to a variable (pattern 3 below).

3 · Mixed method

luma
agent Processor {
  tools: [validate]

  fn run(input: string) -> Final {
    draft      = ask "Extract the raw data: {input}" -> Draft
    normalized = normalize(draft)                    // pure fn
    checked    = tool validate({ value: normalized }) // direct tool
    result     = ask "Validation result: {checked}" -> Final
    return result
  }
}

When an ask is assigned to a variable it can be used together with return. In this pattern you compose the entire flow yourself: the model call, deterministic transformation, tool call and control flow can be mixed freely.

steps permitted inside a mixed method:

  ask  →  pure fn  →  tool  →  ask  →  return
   │        │           │         │
   │        │           │         └─ second LLM turn
   │        │           └─ guaranteed tool execution
   │        └─ deterministic transformation
   └─ typed LLM output

  also: if · else · switch · for · while · break · continue
        try/catch · map · filter · reduce · parallel · dependency call

The ask statement

luma
ask "prompt text {variable}" -> Schema
x = ask "prompt text" -> Schema
x = ask "prompt text" -> Schema retry 3
PartDetail
PromptA single double-quoted line. \n adds a line break; {variable} and a dotted path of any depth ({variable.field}, {pair.left.name}) are interpolated.
-> SchemaThe target schema. It is converted to JSON Schema and sent as response_format, and the response is validated against it.
retry NThe attempt count for this ask; it overrides the agent's retry setting.

Retry behavior

When validation fails, the error text is appended to the prompt and the call is retried:

text
<original prompt>

The previous response failed schema validation. Fix the error and return
only valid JSON. Error: Analysis.confidence must be at most 1.
  • The total number of attempts is retry + 1 (retry: 2 → at most 3 requests).
  • Every attempt has its own attempt_id; the logical_run_id stays the same.
  • The errors of the previous attempt are not carried into the next one; every response is validated from scratch.
  • Model/tool errors with retryable: false stop the loop immediately (for example MODEL_REFUSAL, TOOL_PERMISSION_DENIED).

The system message sent to the model

text
Return only valid JSON matching this schema. Include every required field and
do not add or omit fields: {…generated JSON Schema…}
Available context source IDs: ["contract","spec"]. If the output has a field
named sources, use only these IDs.
Agent role: {the agent's role text}

This message is generated automatically. Your role text is appended to the end of it; your prompt becomes the user message. If memory is enabled on the agent, the past messages go between the two.

Union return type

A method still has one return type. What is new is that the type may be a tagged union — and a single union type covers several concrete schemas, so one method can extract an invoice or a receipt without being split into two methods that the caller then has to choose between.

luma
schema Invoice {
  invoice_number: string
}

schema Receipt {
  reference: string
}

schema Document = Invoice | Receipt tagged by kind

agent Extractor {
  fn extract(kind: string, text: string) -> Document {
    switch kind {
      case "Invoice" { ask "Extract the invoice fields:\n{text}" -> Invoice }
      case "Receipt" { ask "Extract the receipt fields:\n{text}" -> Receipt }
      default { ask "Extract whatever you can:\n{text}" -> Invoice }
    }
  }
}
  • Each branch's ask may target any member of the union, and the branches do not have to agree with one another. A schema from outside the union is refused: Ask schema 'Result' is not a member of tagged union 'Document'.
  • An ask may also target the union itself — fn extract(...) -> Document with a terminal ask … -> Document. Then you are not narrowing to one member: the model is shown every member and its answer's tag picks which one it produced. Use the member form when your code already knows what it is asking for, and the union form when deciding which member applies is the model's job.
  • The tag is written for you: when a terminal ask produces a member, the runtime puts the discriminator (kind: "Invoice") on the result before validating it.
  • The tag is born at the return boundary, not at the ask. A value assigned from a member ask is still member-shaped while it is inside the branch, and only becomes union-shaped as the method returns it. So a value serialized, logged or handed to a tool before the return legitimately has no tag yet — if you need it there, write it yourself or move the work after the return.
  • A return inside a match branch is tagged the same way, and so is one inside a switch branch whose case value is a member name — case "Invoice" is what tells the runtime which member the branch produces.
  • Everywhere else the tag is yours to write, and the parser holds you to it. A switch default branch, a case _ wildcard and both arms of an if/else have nothing to read the member from, so a return there must spell out kind:. Leave it out and the program is stopped before it runs:
Error: Tagged union method 'classify' branch return must explicitly return discriminator 'kind'.

The alternative would have been to guess the member from the fields the branch returns, which is the try-each behaviour a tagged union exists to remove — so the guess is refused and the requirement is made visible at parse time instead. This matters precisely because the totality rule below forces a default or an else onto you.

The producing branch chain has to be total

When a switch or an if chain is the method's only producer, every input must land in one of its branches — otherwise an unmatched input would leave the method producing nothing at all. The missing catch-all is therefore a parse error rather than a run-time surprise:

Error: Switch in method extract requires a default branch for tagged union 'Document'.
Error: If in method extract requires an else branch for tagged union 'Document'.

This is the mirror image of match, which forbids default: there the tag already enumerates the possibilities, so totality is proved by covering the members instead of by adding a fallback.

A branch ask may be written on the line that opens the branch or on a line of its own; both parse identically.

Consuming the result

Whoever receives the value opens it with match, the only construct that narrows a union down to a concrete member:

luma
agent Router {
  fn route(document: Document) -> Result {
    match document {
      case Invoice invoice { return { value: invoice.invoice_number } }
      case Receipt receipt { return { value: receipt.reference } }
    }
  }
}

When the method running the match returns that same union, a return inside a branch does get its discriminator written for it — the branch already names the member.

An assigned ask is not the method's result

Only a terminal ask — one that is not assigned to a variable — is what the method produces, and only that one is measured against the return type. An ask assigned to a variable is an intermediate value: it is validated against its own schema and then used in the body, so it may target any schema at all, whether or not that schema belongs to the union.

luma
agent Router {
  fn route(text: string) -> Document {
    draft = ask "Classify the document: {text}" -> Draft
    switch draft.kind {
      case "Invoice" { ask "Extract the invoice fields:\n{text}" -> Invoice }
      default { ask "Extract the receipt fields:\n{text}" -> Receipt }
    }
  }
}

Draft is not a member of Document and does not need to be. The rule is the same without a union: in a method returning Result, draft = ask … -> Draft is fine, while a terminal branch ask that does not produce Result is refused with Every ask in agent method run must use the method return schema 'Result'; ask 1 uses 'Draft'. — the message says every, but assigned asks are never counted.

Configuration reference

Model and request

KeyTypeDefaultDescription
modelprofile name or stringlegacy gatewayThe name of a model block, or a raw model ID in quotes.
rolestring—The system role. {variable} interpolation is applied.
temperaturefloat0Written into the request body.
max_tokensintfrom the model profileOutput token limit.
timeoutmsfrom the model profileTimeout for a single LLM request.
retryint0Number of retries on a validation error.
run_policypolicy namesingleMulti-model strategy. → Run policy

Tools

KeyTypeDefaultDescription
toolsname list[]Tools that are both exposed to the LLM and callable directly. A tool that is not in the list cannot be called with the tool step either.
tool_timeoutms—Default timeout per tool.
tool_timeout_maxms—Upper bound: the result is min(tool.timeout, tool_timeout_max).
tool_rate_limitint5Upper bound of the tool call counter — see the scope note below.
tool_concurrencyintunlimitedHow many native tools may run at the same time within one provider turn.
tool_failure_limitint—After this many failures the circuit breaker opens → TOOL_CIRCUIT_OPEN.
tool_policyallow · deny_write · approvalallowThe permission model for write tools.
tool_scopesstring listunlimitedThe permitted tool scope values.
tool_contextsstring listallThe contexts a tool may request with needs. An empty list permits none of them.

Memory

KeyTypeDefaultDescription
memoryboolfalseTurns on carrying the conversation history.
memory_scopeinstance · session · shared · persistentinstanceThe sharing scope of the history.
memory_filepath.luma-memory-<Agent>.jsonThe file for the persistent scope.
memory_limitintunlimitedThe number of messages kept; older ones are discarded.
memory_retentionmsunlimitedHistory left untouched for this long is deleted.
memory_accessread_write · read_onlyread_writeread_only reads the history but does not write to it.

Orchestration and budget

KeyTypeDefaultDescription
contextsname listallThe contexts that may be interpolated inside a prompt (allowlist).
parallel_limitintunlimitedConcurrency in an in-method parallel block.
loop_limitint100Upper bound for a while loop.
orchestration_timeoutms—Timeout for the whole method.
cost_per_1k_tokensfloat0The unit price used for cost accounting.
token_budgetint—If it is exceeded, MODEL_BUDGET_EXCEEDED.
cost_budgetfloat—If it is exceeded, MODEL_BUDGET_EXCEEDED.
uses name: Agent(args)declaration—A dependent agent.
tool_rate_limit uses two separate counters

The same setting is enforced in two different places, with counters independent of each other:

ScopeWhen the counter resetsError message
Direct tool stepsOn every method callAgent 'A' tool call limit exceeded (5).
Native tool callsOn every ask call (and on every retry attempt)Tool call limit exceeded (5).

tool_concurrency, on the other hand, is something else entirely: when the model calls more than one tool in a single provider turn, it determines how many of them run at the same time.

Dependency agents

luma
agent Researcher(instruction: string) {
  contexts: [document]
  role: "{instruction}"

  fn research(topic: string, document: string) -> Research {
    ask "Extract the two findings in the document for {topic}.\n\n{document}" -> Research
  }
}

agent Reporter {
  contexts: [document]
  uses researcher: Researcher("Extract two findings from the document; do not add new information.")

  fn report(topic: string, document: string) -> Report {
    research = researcher.research(topic, document)
    ask "Turn the research result into a report.\n\nRESEARCH:\n{research}" -> Report
  }
}
RuleDetail
Declarationuses <local_name>: <AgentName>(<constructor arguments>)
Callx = local_name.method(arg…) — on a line of its own.
ValidationThe constructor argument count and the existence of the method are checked at parse time.
CycleRejected: Agent dependency cycle found: A -> B -> A
ResultThe typed result of the dependent method is assigned directly to the variable.
Reserved namespace collision

An x = name.method(...) line is parsed as a dependency call. For that reason, do not give a dependency the name io, schema, json, math, str, col, date, regex, num or agent — those names are reserved for standard library namespaces and are not recognized as dependencies.

Memory

luma
agent Chatbot {
  role: "Remember the previous conversation."
  memory: true
  memory_scope: "session"
  memory_limit: 6
  memory_retention: 60000
  memory_access: "read_write"

  fn answer(question: string) -> Answer {
    ask "New question: {question}" -> Answer
  }
}

Scopes

ScopeWho the history is shared withInside parallel overUse
instance defaultOnly that instanceisolatedIndependent sessions; first = C() and second = C() do not see each other.
sessionEvery instance of the same agent typeshared · racyA single user, multi-step flow.
sharedAll agentsshared · racyA common context pool; use it carefully.
persistentThe same agent type + a fileshared · racyThe history is preserved when the program runs again.

Behavior

  • The history is kept as pairs of user (the prompt) and assistant (the result converted to JSON) messages.
  • The messages are placed between the system message and the current prompt.
  • When memory_limit is exceeded, the oldest messages are discarded.
  • History left untouched for the memory_retention period is deleted entirely.
  • memory_access: "read_only" reads the history but does not write a new entry.
  • persistent history is written to the file after the method's terminal ask completes.
Memory increases cost

Every message is added to the prompt tokens of the next request. Using the shared scope without giving a memory_limit grows token consumption fast. Putting an upper bound in place with token_budget is a good habit.

Only the default scope is safe inside parallel over

History is keyed by scope, not by instance. Only instance keys per instance; session, shared and persistent key by agent name, so inside a parallel over every element reads and writes one history. Measured over three rows with memory_scope: "session", counting history messages per outgoing request: limit 1 gives 2, 4, 6 — the history is shared and grows — while limit 3 gives 2, 2, 2, all three racing the same store. The prompt actually sent then depends on limit, which is otherwise only a concurrency ceiling. Keep agents on the default scope inside a fan-out.

Agent memory is not std/state

Two things persist between runs and they are not interchangeable. Agent memory is conversation history the runtime manages for you: it is written automatically after a terminal ask, it is shaped as user/assistant message pairs, and persistent has a default file (.luma-memory-<Agent>.json). std/state is a key/value store you write explicitly, holds plain strings, and has no default location — --state-file is mandatory, on the stated principle that a silent default is silent data loss. Reach for memory to give a model context about the conversation; reach for state to remember where a pipeline stopped.

Context access control

luma
context secret = file("docs/secret.md")
context public = file("docs/public.md")

agent Summarizer {
  contexts: [public]       // only "public" can be interpolated

  fn summarize(public: string) -> Summary {
    ask "Summarize:\n{public}" -> Summary
  }
}

If contexts is not given, access to every context is free. When a list is given, trying to interpolate a context name that is not in the list produces an error at run time:

Error: Agent context access was denied: secret

The same list also determines the set of IDs against which the sources field of a schema is validated. That field name is fixed — the runtime recognises sources and nothing else.

parallel inside a method

luma
schema Bundle {
  first: StatsResult
  second: StatsResult
}

agent Pipeline {
  tools: [text_stats]
  parallel_limit: 2

  fn run(text: string) -> Answer {
    bundle = parallel -> Bundle {
      first  = tool text_stats({ text: text })
      second = tool text_stats({ text: text })
    }

    ask "Results: {bundle}" -> Answer
  }
}
  • Every step inside the block runs concurrently; the concurrency is capped by parallel_limit.
  • The result is an object keyed by the step variable names: { first: …, second: … }.
  • If -> Schema is given, the collected object is validated against that schema.
  • ask, tool and dependency calls can be used inside the block.

Top-level parallel: fan-out / fan-in

luma
analyses = parallel -> AnalysisBundle {
  technical = technicalAgent.analyze(topic, document)
  risk      = riskAgent.assess(topic, document)
}

report = editor.combine(analyses)

Options

The option order is fixed

The modifiers must be written in exactly this order; if the order is broken, the line is not recognized as a parallel block:

x = parallel  on_error …  limit …  timeout …  fan_in …  on_conflict …
              fallback inst.method(…)  merge inst.method(…)  -> Schema {
OptionValuesDefaultMeaning
on_errorfail_fast · continue · fallbackfail_fastfail_fast: on the first error the others are cancelled. continue: the errors are collected and the successful branches are returned. fallback: if there is an error, the backup call runs.
limitnumberunlimitedThe number of concurrent branches.
timeoutms—A separate timeout for each branch; a branch that exceeds it is cancelled.
fan_innested · flatnestednested: { branch_name: result }. flat: the keys of the result objects are merged onto a single level.
on_conflicterror · first · lasterrorWhat to do when the same key arrives twice during a flat merge.
fallbackinst.method(args)—Mandatory with on_error fallback.
mergeinst.method(args?)—Passes the merged result to an agent method and produces the final value. If no argument is given, the collected object is passed as the single argument.
-> Schemaschema name—The schema the final result is validated against.

Examples

luma
// Swallow the errors, collect the successes
partial = parallel on_error continue limit 1 {
  good = calc1.add(2, 3)
  bad  = calc2.add("wrong", 3)
}

// Run a backup agent if everything fails
backed = parallel on_error fallback fallback calc3.add(10, 1) {
  bad = calc2.add("wrong", 3)
}

// Flat merge + a dedicated fan-in agent
result = parallel fan_in flat on_conflict last merge combiner.combine -> CalcResult {
  first  = calc1.add(2, 3)
  second = calc2.add(7, 8)
}
on_error continue and _errors

In continue mode the failed branches are added to the result object under the _errors key:

{ "good": { "result": 5 }, "_errors": { "bad": "add.input.a must be a number." } }

If there is an error, -> Schema validation is skipped (otherwise _errors would count as an unknown field).

The same instance cannot be used in two branches

The same agent instance cannot appear in more than one branch of a parallel block: The same agent instance cannot be used more than once in parallel. Create a separate instance for each branch.

parallel over: data fan-out (v0.4.0)

The parallel forms above fan out over declared branches — a fixed set you wrote down. Its sibling parallel over fans out over data, evaluating one body per element:

luma
classified = parallel over rows as row limit 4 -> Classified[] {
  result = classifier.classify(row)
  return result
}

The body follows method-body rules — ask, tool calls, match, for — but parallel over itself is a top-level operation and cannot appear inside an agent method. It has no owning agent; the runtime builds a synthetic one, which is also why each element resolves its dependencies under its own instance key. That isolates history only under the default memory_scope: instance — session, shared and persistent are keyed by agent name, so under those every element shares one history and limit > 1 makes the order a race.

Its full rules — input-order results, the mandatory limit, the fixed option order, and how limit interacts with a datasource's pool_max — are on Data & Integration.

Budgets and limits

luma
agent Controlled {
  model: Primary
  cost_per_1k_tokens: 0.01
  token_budget: 20000
  cost_budget: 0.50
  orchestration_timeout: 120000
  loop_limit: 50
}
the LLM response came back
   ↓
usage read  (prompt_tokens · completion_tokens · total_tokens · reasoning_tokens)
   ↓
cost = total_tokens / 1000 × cost_per_1k_tokens
   ↓
the agent budget state is updated
   ↓
is token_budget or cost_budget exceeded?
   ├── yes → MODEL_BUDGET_EXCEEDED  (retryable: false → the retry loop stops)
   └── no  → the flow continues
  • The budget check happens after the response arrives: the request that exceeds the limit still runs, but the next one is blocked.
  • The budget state is carried through one ask, so every retry attempt of that call draws on the same pool. Run policy candidates each get a fresh pool — except under escalate, where the whole ladder shares one.
  • If cost_per_1k_tokens is not given, the cost is computed as 0 and cost_budget never fires.
  • orchestration_timeout covers the whole method; if it is exceeded, Agent 'A.f' orchestration timeout occurred (N ms).
LimitScopeDefault
timeoutA single LLM requestfrom the model profile
orchestration_timeoutThe whole methodunlimited
tool_timeout / tool_timeout_maxA single tool executionunlimited
loop_limitwhile iteration100
tool_rate_limitTool call count5
token_budget / cost_budgetAn agent method callunlimited