Curiosity

Sub-agent Workflows

Once an agent can call another agent through an InvokeAgent tool, you have the primitive you need for multi-step workflows: research, summarisation, aggregation. The outer agent is the planner; specialists handle the legwork; everything reports back as structured JSON.

This page walks through three reference workflows. Each pairs a specialist agent (prompt + tools + model) with the tool wrapper that exposes it.

Architecture

flowchart TD User[User question] --> Planner["Planner agent<br/>(small model)"] Planner -->|InvokeAgent KBResearch| Research["KB Research<br/>specialist"] Planner -->|InvokeAgent Aggregator| Agg["Aggregator<br/>specialist"] Planner -->|InvokeAgent Summariser| Sum["Summariser<br/>specialist"] Research --> R1[CreateSearchAsUserAsync] Research --> R2[StartAtSimilarTextAsync] Agg --> A1["IQuery aggregation<br/>(graph traversal)"] Sum --> S1[GetTextFromNode] Planner --> Answer[Final answer]

Three principles hold across every workflow on this page:

  1. The planner is small and stateless. Use a fast model. Its job is to decide which specialist to call next, not to do the work.
  2. Specialists are deep and typed. Larger model, structured OutputSchema, focused tool set. The result is something the planner can quote verbatim.
  3. Identity flows everywhere. scope.CurrentUser on the planner is the userUID passed to the specialist is the user whose ACL filters every graph and search call. scope.CurrentChat propagates the same way, so every sub-run is grouped under the parent chat.

Workflow 1 — Research with citations

A support assistant gets a complex question ("why is my MBA-2024 hot on battery?"). Instead of dumping search results into the chat, it delegates to a research specialist that runs multiple searches, picks evidence, and returns a ResearchBrief.

The specialist agent

[agent: Curiosity.Agents.Name("KB Research")]
[agent: Curiosity.Agents.Description("Cross-reference the KB and support cases to answer a technical question with citations.")]
[agent: Curiosity.Agents.ChatTask("01HQ…Sonnet")]   // larger model for synthesis
[agent: Curiosity.Agents.OutputSchema("ResearchBrief")]
[agent: Curiosity.Agents.Tool("01J…SearchKB")]
[agent: Curiosity.Agents.Tool("01J…SimilarCases")]
[agent: Curiosity.Agents.Tool("01J…FetchArticle")]

You research support questions for ${PRODUCT_SKU:-the user's product}.

Step 1. Call SearchKB with the user's question to find candidate articles.
Step 2. Call SimilarCases with the user's question to find historically similar cases.
Step 3. For the top 3 candidates from step 1 or 2, call FetchArticle to read the body.
Step 4. Return a ResearchBrief with citations to the actual articles/cases you read.

Do NOT invent article IDs. If no evidence is found, return an empty brief.

The schema and the tools

[AgentOutputSchema]
public record ResearchBrief(
    string Summary,
    Finding[] Findings,
    string[] CitedUIDs);

public record Finding(string Title, string Evidence, string SourceUID);

SearchKB — hybrid text + semantic search via CreateSearchAsUserAsync:

[Tool("Search the product knowledge base for articles matching a question. Returns scored snippets.")]
public static async Task<string> SearchKB(ToolScope scope,
    [Parameter("The question in the user's own words.", required: true)]
    string question,
    [Parameter("Optional product SKU to scope the search.", required: false)]
    string productSku = null,
    [Parameter("Maximum number of results.", required: false)]
    int limit = 10)
{
    var req = SearchRequest.For(question);
    req.BeforeTypesFacet = new HashSet<string> { N.KBArticle.Type };
    req.HybridSearch     = true;
    req.SemanticRerank   = true;

    if (!string.IsNullOrWhiteSpace(productSku))
        req.WithTargetQuery(() => scope.Graph.Q()
            .StartAt(N.Product.Type, productSku)
            .In(N.KBArticle.Type, E.AboutProduct));

    var q = await scope.Graph.CreateSearchAsUserAsync(
        req, scope.CurrentUser, scope.CancellationToken);

    var hits = q.Take(limit).AsScoredUIDEnumerable()
        .Select(s => {
            scope.Graph.TryGetReadOnlyContent<KBArticle>(s.UID.UID, out var n);
            var text = scope.ChatAI.GetTextFromNode(s.UID.UID, limit: 4_000);
            var id   = scope.AddSnippet(uid: s.UID.UID, text: text);
            return new {
                snippetId = id,
                uid       = s.UID.UID.ToString(),
                title     = n?.Title ?? "",
                score     = s.Score,
                excerpt   = text
            };
        })
        .ToArray();

    return hits.ToJson();
}

SimilarCases — vector search via IQuery.StartAtSimilarTextAsync:

[Tool("Find historically similar support cases by semantic similarity to a description. " +
      "Use for 'have we seen this before?' questions.")]
public static async Task<string> SimilarCases(ToolScope scope,
    [Parameter("The symptom or question.", required: true)]
    string description,
    [Parameter("Number of cases to return.", required: false)]
    int count = 10)
{
    var arctic = scope.Graph.Indexes
        .OfType<SentenceEmbeddingsIndex>(N.SupportCase.Type)
        .FirstOrDefault(i => i.SentenceEncoderModel == SentenceEncoderModel.ArcticXS);

    if (arctic is null) return "{\"error\":\"No embeddings index on SupportCase.\"}";

    var q = await scope.Graph.Q().StartAtSimilarTextAsync(
        text:      description,
        count:     count,
        nodeTypes: new[] { N.SupportCase.Type },
        indexUID:  arctic.UID,
        applyCutoff: true);

    var results = q.AsScoredUIDEnumerable().Select(s => {
        scope.Graph.TryGetReadOnlyContent<SupportCase>(s.UID.UID, out var c);
        var snippetId = scope.AddSnippet(uid: s.UID.UID,
            text: scope.ChatAI.GetTextFromNode(s.UID.UID, 2_000));
        return new { snippetId, uid = s.UID.UID.ToString(),
                     subject = c?.Subject, score = s.Score };
    }).ToArray();

    return results.ToJson();
}

FetchArticle — pull the full body of a single node (cheap, deterministic):

[Tool("Fetch the full body of a knowledge-base article or case by UID.")]
public static string FetchArticle(ToolScope scope,
    [Parameter("The node UID.", required: true)] string uid)
{
    var nodeUID = UID128.Parse(uid);
    var text = scope.ChatAI.GetTextFromNode(nodeUID, limit: 12_000);
    var snippetId = scope.AddSnippet(uid: nodeUID, text: text);
    return new { snippetId, uid, text }.ToJson();
}

The planner that calls it

The outer chat assistant only needs the InvokeAgent wrapper (or the dedicated KBResearchTool wrapper). One line in its prompt:

When the user asks a technical question that needs evidence,
delegate to the 'KB Research' specialist via InvokeAgent and quote
its ResearchBrief in your reply, preserving the citations.

Workflow 2 — Aggregation over the graph

Aggregations are deterministic by nature — they shouldn't go through an LLM. The agent's job is to pick an aggregation and narrate the result; the tool runs the actual IQuery traversal.

The aggregation tool

public record CategoryCount(string Category, int Count);

[Tool("Count support cases by category for a given time window. Returns a list of {category, count} " +
      "pairs sorted descending. Use for 'how many', 'top N', or 'breakdown' questions.")]
public static async Task<string> CountCasesByCategory(ToolScope scope,
    [Parameter("ISO-8601 start of the window (inclusive).", required: true)]
    string from,
    [Parameter("ISO-8601 end of the window (exclusive).",   required: true)]
    string to,
    [Parameter("Optional product SKU to scope the count.",  required: false)]
    string productSku = null)
{
    var fromTs = DateTimeOffset.Parse(from);
    var toTs   = DateTimeOffset.Parse(to);

    // Start from all SupportCase nodes the caller can see, then traverse to Category.
    var cases = scope.Graph.Q(scope.CurrentUser)
        .StartAt(N.SupportCase.Type)
        .Where(N.SupportCase.Opened, fromTs, toTs);

    if (!string.IsNullOrWhiteSpace(productSku))
        cases = cases.Out(N.Product.Type, E.AboutProduct)
                     .Where(N.Product.SKU, productSku)
                     .In(N.SupportCase.Type, E.AboutProduct);

    var categoryCounts = cases
        .Out(N.Category.Type, E.InCategory)
        .AsEnumerable()
        .GroupBy(n => n.GetString(N.Category.Name))
        .Select(g => new CategoryCount(g.Key, g.Count()))
        .OrderByDescending(c => c.Count)
        .ToList();

    scope.SetToolCallDisplayName($"Counted cases {fromTs:yyyy-MM-dd}…{toTs:yyyy-MM-dd}");
    return categoryCounts.ToJson();
}

Wiring it up

Attach CountCasesByCategory to an Analyst specialist whose only job is to choose the right aggregation and report numbers in prose. The planner delegates via InvokeAgent("Analyst", "Top 5 case categories last week for SKU MBA-2024") and gets back a numerical breakdown plus a one-sentence summary.

Unknown component: alert Keep aggregation tools stateless and bounded — always require a time window or a UID list. An LLM happily asks "count all cases ever"; the tool should refuse or page. [!/alert]

Workflow 3 — Similarity + summarisation

A "customer digest" workflow: given a customer account, find similar accounts, pull their open cases, summarise common themes. Three tools, one planner, one summariser.

Similar accounts via IQuery.ToSimilarity

[Tool("Find accounts similar to a seed account using behaviour + product-mix signals. " +
      "Returns up to N scored UIDs.")]
public static string SimilarAccounts(ToolScope scope,
    [Parameter("Account UID of the seed.", required: true)]
    string accountUid,
    [Parameter("How many similar accounts to return.", required: false)]
    int count = 25)
{
    var seed = UID128.Parse(accountUid);

    var scenario = scope.Graph.Q().StartAt(seed)
        .ToSimilarity(options => options.MaxResults(count))
        .AddSignal("by-product-mix", s => s
            .FromQuery(q => q.StartAt(seed)
                              .Out(N.Product.Type, E.Purchased)
                              .In(N.Account.Type,  E.Purchased))
            .Weight(2.0))
        .AddSignal("by-segment", s => s
            .FromQuery(q => q.StartAt(seed)
                              .Out(N.Segment.Type, E.InSegment)
                              .In(N.Account.Type,  E.InSegment))
            .Weight(1.0));

    var results = scenario.Run(scope.CurrentUser).Take(count);

    return results.Select(r => new {
        uid   = r.UID.ToString(),
        score = r.Score
    }).ToArray().ToJson();
}

Open cases for an account

[Tool("List open support cases for an account, newest first.")]
public static string OpenCases(ToolScope scope,
    [Parameter("The account UID.", required: true)]
    string accountUid,
    [Parameter("Maximum number of cases.", required: false)]
    int limit = 20)
{
    var accUID = UID128.Parse(accountUid);

    var cases = scope.Graph.Q(scope.CurrentUser)
        .StartAt(accUID)
        .Out(N.SupportCase.Type, E.AboutAccount)
        .Where(N.SupportCase.Status, "Open")
        .SortBy(N.SupportCase.Opened, descending: true)
        .Take(limit)
        .AsEnumerable()
        .Select(n => new {
            uid     = n.UID.ToString(),
            subject = n.GetString(N.SupportCase.Subject),
            opened  = n.Get<DateTimeOffset?>(N.SupportCase.Opened)
        });

    return cases.ToArray().ToJson();
}

The Summariser specialist

A specialist that takes a list of UIDs (cases, articles, anything text-bearing) and returns a themed summary:

[agent: Curiosity.Agents.Name("Summariser")]
[agent: Curiosity.Agents.Description("Summarise a list of nodes by recurring themes.")]
[agent: Curiosity.Agents.ChatTask("01HQ…Sonnet")]
[agent: Curiosity.Agents.OutputSchema("ThemedSummary")]
[agent: Curiosity.Agents.Tool("01J…FetchArticle")]

You receive a JSON array of UIDs. For each UID, call FetchArticle to read
the content. Group the bodies into 2–5 themes; return a ThemedSummary
with a one-line description of each theme and the UIDs that supplied it.

Do not include UIDs you did not actually read.
[AgentOutputSchema]
public record ThemedSummary(Theme[] Themes);
public record Theme(string Title, string Description, string[] UIDs);

The planner

A tiny prompt drives the whole workflow:

For a 'digest of customer X' request:
1. Call SimilarAccounts(accountUid=X) to find peers.
2. For X and its top 5 peers, call OpenCases.
3. Pass the combined case UID list to the Summariser via
   InvokeAgent('Summariser', JSON-of-UIDs).
4. Report the ThemedSummary back to the user, citing each theme's UIDs.

The planner runs on a small model; the heavy reading happens in the Summariser, against a larger model. Each step's tool result is small (UIDs, counts), so prompt budget stays predictable.

Cross-cutting patterns

Bounding cost

A naive planner can call InvokeAgent in a loop. Always:

  • Constrain the planner's tool catalog. One InvokeAgent per specialty, no generic "call any agent".
  • Set a hard cap in the prompt ("You may delegate at most 3 sub-tasks.").
  • Wrap the outer endpoint in Pooling mode (see Creating Endpoints).

Caching deterministic sub-results

Research and aggregation results are often re-usable across users in the same role. Persist them as graph nodes keyed by the input:

var cacheKey = $"research:{question.ToLowerInvariant().Hash()}";
if (scope.Graph.TryGetReadOnlyContent<_CachedResearch>(cacheKey, out var cached)
    && cached.Created > DateTimeOffset.UtcNow.AddHours(-1))
    return cached.Brief;

The cache is graph-native, ACL-aware (drop entries the caller can't see), and re-usable across the planner, the front-end, and scheduled tasks.

Observability

Every RunAgentAsync call writes an _AgentRun node. Passing chatUID: scope.CurrentChat already groups parent and child runs under the same chat — that's the cheapest way to reconstruct a call tree. For workflows that span multiple chats (or no chat at all), tag the inner call with the outer run's UID through a variable:

variables["PARENT_RUN"] = parentRunUID.ToString();

A scheduled task can then walk _AgentRun nodes by PARENT_RUN to render call trees in your monitoring dashboard.

Failure modes

Failure What to do
Sub-agent returns malformed JSON The outer model sees the failure as a tool error and can retry or fall through. Don't catch.
Sub-agent times out (5 min default) The outer call fails too. Either raise the timeout for the specialist, or split it further.
Recursive call (A invokes A) Not detected by the runtime. Encode the rule in the wrapper's allowed-names list.
Permission mismatch (user can't see specialist) Wrapper returns { error: "Unknown agent…" }. Plan for it in the outer prompt.

Checklist for shipping a sub-agent workflow

  • Planner uses a small model (Haiku-class) and a focused tool catalog.
  • Each specialist pins its own ChatTaskUID.
  • Each specialist has an OutputSchema — results are structured, not prose.
  • Tools at every level call CreateSearchAsUserAsync and Graph.Q(scope.CurrentUser) — never the admin variants.
  • Wrappers thread chatUID: scope.CurrentChat through scope.AgentAI.RunAgentAsync so parent/child runs stay grouped.
  • The outer endpoint is in Pooling mode with a sensible upper bound on sub-agent calls.
  • Recursive invocation is explicitly disallowed in the wrapper.

See also

© 2026 Curiosity. All rights reserved.