Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
APP_NAME=Fuse

PORT=9090
# Path prefix when hosted behind a reverse proxy (e.g. api.example.com/fuse) so the Swagger UI at
# /docs works under the prefix. The proxy's X-Forwarded-Prefix header takes precedence if set.
# SERVER_BASE_PATH=/fuse

# Database: memory | postgres
DB_DRIVER=memory
Expand Down
Empty file removed MISSING.md
Empty file.
10 changes: 9 additions & 1 deletion docs/adr/0028-agent-prompt-context-and-memory-model.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# 0028. Agent prompt / context & conversation-memory model

- Status: Proposed
- Status: Accepted (Option A — bounded context-assembly policy — shipped; session store / RAG deferred)
- Date: 2026-06-02
- Deciders: FUSE maintainers

Expand Down Expand Up @@ -67,6 +67,14 @@ multi-turn requirements are concrete.

## More Information

- **Option A shipped**: `ai/agent` accepts optional `maxContextTokens` (an approximate token budget)
and `contextStrategy` (`drop-oldest` default | `summarize`). Before each completion the running
transcript is bounded by `trimContext` (`internal/packages/functions/ai/context.go`), which
preserves the leading system turns + the first user task and the most recent turns, dropping the
oldest middle turns; `summarize` replaces them with one LLM-generated summary turn (an extra,
opt-in model call — recorded as a `steps` entry). Token estimation is a coarse ~4-chars/token
heuristic, centralized so a real tokenizer can replace it. Absent budget = unchanged behavior.
**Deferred**: Option B (persisted cross-run session store) and Option C (vector/RAG memory).
- Current assembly: `internal/packages/functions/ai/agent.go` (per-run message slice).
- Related: [ADR-0007](0007-agent-reasoning-loop-and-tools-from-functions.md),
[ADR-0019](0019-object-store-payload-externalization.md),
Expand Down
13 changes: 12 additions & 1 deletion docs/adr/0030-structured-output-enforcement.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# 0030. Structured / JSON output enforcement for `ai/chat` & `ai/agent`

- Status: Proposed
- Status: Accepted (tool-forced "respond" path shipped; provider-native `response_format` deferred)
- Date: 2026-06-02
- Deciders: FUSE maintainers

Expand Down Expand Up @@ -68,6 +68,17 @@ provider supports native structured output, use it (B); otherwise fall back to t

## More Information

- **Shipped (Option C, tool-forced — universal)**: `ai/chat` and `ai/agent` accept an optional
`outputSchema` input (a list of `{name,type,required,description}` ParameterSchema). When set, the
node offers a single forced `respond` tool whose parameters are the JSON Schema
(`ParameterSchemaToJSONSchema`) with `ToolChoice: "required"`, parses the tool-call arguments, and
validates them against the schema; one bounded repair retry on invalid output, then a
`FunctionError`. The validated object becomes the node's `output` (instead of free-form text); the
agent runs its normal loop, then coerces the final answer. Absent `outputSchema` = unchanged.
Works on every provider (all support tool calling). Logic in
`internal/packages/functions/ai/structured.go`. **Deferred (Option B optimization)**:
provider-native `response_format` / `json_schema` with a per-provider capability map for providers
that support it natively.
- Reuse: `internal/packages/functions/ai/tools.go` (`ParameterSchemaToJSONSchema`),
`pkg/workflow/metadata.go` (`ParameterSchema`).
- Related: [ADR-0006](0006-llm-provider-abstraction-and-multi-provider-strategy.md),
Expand Down
11 changes: 6 additions & 5 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,9 @@ copying [`template.md`](template.md).
| 0025 | [Browser automation & web-scraping package](0025-browser-automation-and-web-scraping-package.md) | Proposed | 2026-06-01 |
| 0026 | [Agent-as-orchestrator mode](0026-agent-as-orchestrator-mode.md) | Proposed | 2026-06-02 |
| 0027 | [Async tool invocation via a sub-execution channel](0027-async-tool-invocation-sub-execution-channel.md) | Proposed | 2026-06-02 |
| 0028 | [Agent prompt/context & conversation-memory model](0028-agent-prompt-context-and-memory-model.md) | Proposed | 2026-06-02 |
| 0028 | [Agent prompt/context & conversation-memory model](0028-agent-prompt-context-and-memory-model.md) | Accepted | 2026-06-02 |
| 0029 | [LLM cost & token-usage tracking and budgets](0029-llm-cost-and-usage-tracking-and-budgets.md) | Accepted | 2026-06-02 |
| 0030 | [Structured/JSON output enforcement for ai nodes](0030-structured-output-enforcement.md) | Proposed | 2026-06-02 |
| 0030 | [Structured/JSON output enforcement for ai nodes](0030-structured-output-enforcement.md) | Accepted | 2026-06-02 |
| 0031 | [Settings, secrets & environments: a SecretStore seam](0031-settings-secrets-and-environments.md) | Accepted | 2026-06-02 |
| 0032 | [Sub-workflow composition: child workflows as first-class instances](0032-sub-workflow-composition.md) | Accepted | 2026-06-03 |
| 0033 | [Dependency injection & app composition with uber-go/fx](0033-dependency-injection-and-app-composition.md) | Accepted | 2026-06-03 |
Expand All @@ -66,8 +66,9 @@ copying [`template.md`](template.md).
ADRs **0026–0030** are a cohesive **agent-capabilities** series for the next phase of the AI agent
([ADR-0005](0005-ai-agents-as-workflow-nodes-phased-roadmap.md)) — orchestrator mode (0026), async
tool invocation (0027), prompt/context & memory (0028), cost/usage tracking & budgets (0029), and
structured-output enforcement (0030). The leaf capabilities are being implemented first: **0029**
Phase A (usage visibility) is `Accepted`/shipped; 0028 and 0030 follow; the larger orchestrator
(0026) + async-tools (0027) pair stays `Proposed` until reassessed. **0025** (browser-automation
structured-output enforcement (0030). The leaf capabilities shipped first: **0028**
(context policy), **0029** Phase A (usage visibility), and **0030** (structured output) are
`Accepted`. The larger orchestrator (0026) + async-tools (0027) pair stays `Proposed` until
reassessed. **0025** (browser-automation
package) is an independent, parallel stream, not part of that series. When an ADR is implemented its
status moves to `Accepted` and its "More Information" records what shipped (as 0031 does).
19 changes: 17 additions & 2 deletions internal/actors/mux_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package actors
import (
"net/http"
"strconv"
"strings"

"ergo.services/ergo/act"
"ergo.services/ergo/gen"
Expand Down Expand Up @@ -84,10 +85,24 @@ func (m *muxServer) Init(_ ...any) error {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(spec.ReadDoc()))
})
// When FUSE is hosted under a reverse-proxy path prefix (e.g. /fuse), the /docs redirect and
// the Swagger UI spec URL must carry that prefix or the browser drops it. The prefix comes from
// the proxy's X-Forwarded-Prefix header when set, else SERVER_BASE_PATH.
configuredBasePath := strings.TrimRight(m.config.Server.BasePath, "/")
muxRouter.HandleFunc("/docs", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/docs/index.html", http.StatusMovedPermanently)
prefix := strings.TrimRight(r.Header.Get("X-Forwarded-Prefix"), "/")
if prefix == "" {
prefix = configuredBasePath
}
http.Redirect(w, r, prefix+"/docs/index.html", http.StatusMovedPermanently)
})
muxRouter.PathPrefix("/docs/").Handler(httpSwagger.WrapHandler)
// The UI loads its spec relatively ("doc.json" resolves under <prefix>/docs/) so it works
// behind any prefix; a configured base path makes it explicit/absolute.
specURL := "doc.json"
if configuredBasePath != "" {
specURL = configuredBasePath + "/docs/doc.json"
}
muxRouter.PathPrefix("/docs/").Handler(httpSwagger.Handler(httpSwagger.URL(specURL)))
m.Log().Info("swagger documentation available at /docs or /docs/index.html")

// create and spawn a web server meta-process
Expand Down
5 changes: 5 additions & 0 deletions internal/app/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,11 @@ type (
ServerConfig struct {
Host string `env:"HOST" envDefault:"0.0.0.0"`
Port string `env:"PORT" envDefault:"9090"`
// BasePath is the URL path prefix the engine is reachable under when behind a
// reverse proxy (e.g. "/fuse" for https://api.example.com/fuse). It is used to build
// the Swagger UI redirect/spec URL so /docs works under the prefix. The proxy's
// X-Forwarded-Prefix header takes precedence when present. Empty = served at root.
BasePath string `env:"SERVER_BASE_PATH"`
}

// ClusterConfig configuration for ergo distributed clustering
Expand Down
97 changes: 88 additions & 9 deletions internal/packages/functions/ai/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"strings"
"time"

"github.com/open-source-cloud/fuse/internal/packages/transport"
Expand Down Expand Up @@ -42,6 +43,9 @@ func AgentFunctionMetadata() workflow.FunctionMetadata {
{Name: "temperature", Type: "float", Required: false, Description: "Sampling temperature; if omitted the provider default is used"},
{Name: "maxIterations", Type: "int", Required: false, Default: defaultMaxIterations, Description: "Maximum reasoning iterations (clamped to [1, 25])"},
{Name: "allowedTools", Type: "array", Required: false, Description: "Optional allowlist of full function ids the agent may use as tools; empty means all eligible tools"},
{Name: "maxContextTokens", Type: "int", Required: false, Description: "Optional token budget for the running transcript (approximate); 0/absent disables trimming (ADR-0028)"},
{Name: "contextStrategy", Type: "string", Required: false, Description: "When over maxContextTokens: 'drop-oldest' (default) or 'summarize' (an extra LLM call summarizes dropped turns)"},
{Name: "outputSchema", Type: "array", Required: false, Description: "Optional list of {name,type,required,description} fields; when set the final output is a validated object matching this schema (ADR-0030)"},
},
Edges: workflow.InputEdgeMetadata{
Count: 0,
Expand Down Expand Up @@ -81,10 +85,13 @@ func makeAgentFunction(providers llm.Registry, tools ToolRegistry, usage UsageRe
model: input.GetStr("model"),
temp: optionalTemperature(input),
maxIters: clampIterations(input.GetInt("maxIterations")),
wfID: execInfo.WorkflowID,
execID: execInfo.ExecID,
environment: execInfo.Environment,
usage: usage,
wfID: execInfo.WorkflowID,
execID: execInfo.ExecID,
environment: execInfo.Environment,
usage: usage,
maxContextTokens: input.GetInt("maxContextTokens"),
contextStrategy: contextStrategyOrDefault(input.GetStr("contextStrategy")),
outputSchema: parseOutputSchema(input),
}

messages := make([]llm.Message, 0, 4)
Expand Down Expand Up @@ -125,10 +132,13 @@ type agentExecutor struct {
model string
temp *float32
maxIters int
wfID workflow.ID
execID workflow.ExecID
environment string
usage UsageRecorder
wfID workflow.ID
execID workflow.ExecID
environment string
usage UsageRecorder
maxContextTokens int
contextStrategy string
outputSchema []workflow.ParameterSchema
}

// run drives the reasoning loop until a final answer, an error, or the iteration
Expand All @@ -138,6 +148,12 @@ func (e *agentExecutor) run(ctx context.Context, messages []llm.Message) workflo
steps := make([]map[string]any, 0)

for i := 0; i < e.maxIters; i++ {
// Bound the growing transcript to the configured token budget (ADR-0028).
if trimmed, step := e.applyContextPolicy(ctx, messages); step != nil {
messages = trimmed
steps = append(steps, step)
}

resp, err := e.provider.Chat(ctx, llm.ChatRequest{
Model: e.model,
Messages: messages,
Expand All @@ -160,6 +176,17 @@ func (e *agentExecutor) run(ctx context.Context, messages []llm.Message) workflo
messages = append(messages, resp.Message)

if len(resp.Message.ToolCalls) == 0 {
// Structured output (ADR-0030): coerce the final answer into the requested schema.
if len(e.outputSchema) > 0 {
obj, u, serr := structuredOutput(ctx, e.provider, e.model, messages, e.outputSchema, e.usage, AgentFunctionID)
if serr != nil {
return errorOutput(fmt.Sprintf("ai/agent: structured output failed: %v", serr))
}
totalUsage.PromptTokens += u.PromptTokens
totalUsage.CompletionTokens += u.CompletionTokens
totalUsage.TotalTokens += u.TotalTokens
return successOutputData(obj, totalUsage, steps)
}
return successOutput(resp.Message.Content, totalUsage, steps)
}

Expand All @@ -173,6 +200,52 @@ func (e *agentExecutor) run(ctx context.Context, messages []llm.Message) workflo
return errorOutput("ai/agent: max iterations reached")
}

// applyContextPolicy bounds the running transcript to maxContextTokens (ADR-0028). It returns the
// trimmed messages and a step record, or (nil, nil) when no trimming was needed.
func (e *agentExecutor) applyContextPolicy(ctx context.Context, messages []llm.Message) ([]llm.Message, map[string]any) {
if e.maxContextTokens <= 0 {
return nil, nil
}
kept, dropped := trimContext(messages, e.maxContextTokens)
if len(dropped) == 0 {
return nil, nil
}
step := map[string]any{"context": "trimmed", "strategy": e.contextStrategy, "droppedTurns": len(dropped)}
if e.contextStrategy == contextStrategySummarize {
if summary := e.summarizeDropped(ctx, dropped); summary != "" {
h := headLen(messages)
summaryTurn := llm.Message{Role: llm.RoleSystem, Content: "Summary of earlier turns: " + summary}
out := concatMessages(messages[:h], append([]llm.Message{summaryTurn}, kept[h:]...))
step["summarized"] = true
return out, step
}
}
return kept, step
}

// summarizeDropped asks the provider to summarize the dropped turns into one note. Returns "" on
// error, so the caller falls back to plain drop-oldest.
func (e *agentExecutor) summarizeDropped(ctx context.Context, dropped []llm.Message) string {
var b strings.Builder
for _, m := range dropped {
fmt.Fprintf(&b, "%s: %s\n", m.Role, m.Content)
}
resp, err := e.provider.Chat(ctx, llm.ChatRequest{
Model: e.model,
Messages: []llm.Message{
{Role: llm.RoleSystem, Content: "Summarize the following conversation turns concisely, preserving facts, tool results, and decisions."},
{Role: llm.RoleUser, Content: b.String()},
},
})
if err != nil {
log.Warn().Err(err).Msg("ai/agent context summarization failed; dropping oldest turns instead")
return ""
}
e.usage.RecordCall(AgentFunctionID, e.provider.Name(), e.model, "success")
e.usage.RecordUsage(AgentFunctionID, e.provider.Name(), e.model, resp.Usage)
return resp.Message.Content
}

// executeToolCall resolves, invokes, and records a single model-requested tool
// call. It never aborts the run: unknown tools, bad arguments, invocation errors,
// and tool errors are all fed back to the model as the tool's result so it can
Expand Down Expand Up @@ -282,8 +355,14 @@ func marshalToolContent(data map[string]any) string {

// successOutput builds the agent's final successful output.
func successOutput(answer string, usage llm.Usage, steps []map[string]any) workflow.FunctionOutput {
return successOutputData(answer, usage, steps)
}

// successOutputData builds the agent's final output; output is the answer text or, for structured
// output (ADR-0030), the validated object.
func successOutputData(output any, usage llm.Usage, steps []map[string]any) workflow.FunctionOutput {
return workflow.NewFunctionSuccessOutput(map[string]any{
"output": answer,
"output": output,
"usage": map[string]any{
"promptTokens": usage.PromptTokens,
"completionTokens": usage.CompletionTokens,
Expand Down
26 changes: 26 additions & 0 deletions internal/packages/functions/ai/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@ func ChatFunctionMetadata() workflow.FunctionMetadata {
Required: false,
Description: "Sampling temperature; if omitted the provider default is used",
},
{
Name: "outputSchema",
Type: "array",
Required: false,
Description: "Optional list of {name,type,required,description} fields; when set the output is a validated object matching this schema (ADR-0030)",
},
},
Edges: workflow.InputEdgeMetadata{
Count: 0,
Expand Down Expand Up @@ -107,6 +113,7 @@ func makeChatFunction(providers llm.Registry, usage UsageRecorder) workflow.Func
Messages: messages,
Temperature: optionalTemperature(input),
}
outputSchema := parseOutputSchema(input)

// Provider resolution and the completion run in their own goroutine and report back via
// Finish so the WorkflowFunc pool worker is freed immediately (mirrors logic/timer).
Expand All @@ -123,6 +130,25 @@ func makeChatFunction(providers llm.Registry, usage UsageRecorder) workflow.Func
return
}

// Structured output (ADR-0030): coerce the answer into the requested schema.
if len(outputSchema) > 0 {
obj, u, serr := structuredOutput(ctx, provider, req.Model, messages, outputSchema, usage, ChatFunctionID)
if serr != nil {
log.Error().Err(serr).Str("provider", provider.Name()).Msg("ai/chat structured output failed")
execInfo.Finish(workflow.NewFunctionOutput(workflow.FunctionError, map[string]any{"error": serr.Error()}))
return
}
execInfo.Finish(workflow.NewFunctionSuccessOutput(map[string]any{
"output": obj,
"usage": map[string]any{
"promptTokens": u.PromptTokens,
"completionTokens": u.CompletionTokens,
"totalTokens": u.TotalTokens,
},
}))
return
}

resp, err := provider.Chat(ctx, req)
if err != nil {
usage.RecordCall(ChatFunctionID, provider.Name(), req.Model, "error")
Expand Down
Loading
Loading