feat(tools): add agentic_map and llm_map operators with FlatBuffers persistence
pkg/tools/agentic_map.go + agentic_map_test.go
- agentic_map: batch operator that dispatches each item to a subagent
worker; supports configurable concurrency, retries, and timeout per item
- Items are processed in parallel up to MaxWorkers; failed items are
retried up to MaxRetries with exponential backoff
- Results are collected into a structured MapResult with per-item status
pkg/tools/llm_map.go + llm_map_test.go
- llm_map: batch operator that applies a prompt template to each item
using a direct LLM call (no subagent overhead)
- Supports Jinja-style {{item}} template substitution and structured
JSON output extraction from LLM responses
pkg/tools/map_runtime.go + map_runtime_integration_test.go
- MapRuntime: manages the lifecycle of a map operator run (create, update,
complete, fail); persists run state via memory delegate
- Tracks per-item status transitions and aggregates run-level metrics
pkg/tools/map_run_tools.go
- map_run_status: returns the current status and progress of a running
map operation; enables the agent to monitor long-running batch jobs
- map_run_cancel: cancels an in-progress map run
pkg/tools/map_boundary.go
- MapBoundary: defines the input/output contract for map operators;
validates item schemas and enforces output format requirements
pkg/tools/map_payloads.fbs
- FlatBuffers schema for MapRunSpec, MapItemInput, MapItemOutput;
enables zero-copy serialization of map operator payloads
pkg/tools/mapopsfb/MapRunSpec.go
pkg/tools/mapopsfb/MapItemInput.go
pkg/tools/mapopsfb/MapItemOutput.go
- Generated FlatBuffers Go bindings for map operator payload types
pkg/tools/map_flatbuffer_codec.go + map_flatbuffer_codec_test.go
- MapFlatbufferCodec: bidirectional codec between Go map payload structs
and FlatBuffers wire format; used by MapRuntime for persistence
This commit is contained in:
parent
b03e42bceb
commit
00802f78be
14 changed files with 3253 additions and 0 deletions
334
pkg/tools/agentic_map.go
Normal file
334
pkg/tools/agentic_map.go
Normal file
|
|
@ -0,0 +1,334 @@
|
||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
memsqlc "github.com/ZanzyTHEbar/dragonscale/pkg/memory/sqlc"
|
||||||
|
jsonv2 "github.com/go-json-experiment/json"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AgenticMapTool maps tasks over items using subagent execution with retries.
|
||||||
|
type AgenticMapTool struct {
|
||||||
|
manager *SubagentManager
|
||||||
|
originChannel string
|
||||||
|
originChatID string
|
||||||
|
runtime *MapRuntime
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAgenticMapTool(manager *SubagentManager) *AgenticMapTool {
|
||||||
|
return &AgenticMapTool{
|
||||||
|
manager: manager,
|
||||||
|
originChannel: "cli",
|
||||||
|
originChatID: "direct",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *AgenticMapTool) SetRuntime(runtime *MapRuntime) {
|
||||||
|
t.runtime = runtime
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *AgenticMapTool) Name() string {
|
||||||
|
return "agentic_map"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *AgenticMapTool) Description() string {
|
||||||
|
return "Run subagent processing over each item with retries. Supports worker-backed runs and generated run reads."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *AgenticMapTool) Parameters() map[string]interface{} {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"items": map[string]interface{}{
|
||||||
|
"type": "array",
|
||||||
|
"description": "Items to process via subagent execution.",
|
||||||
|
},
|
||||||
|
"input_jsonl": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Boundary-only JSONL input. Exactly one of items or input_jsonl is required.",
|
||||||
|
},
|
||||||
|
"task_template": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Task template. Supports {{item_json}} and {{index}} placeholders.",
|
||||||
|
},
|
||||||
|
"max_retries": map[string]interface{}{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Retries per item after the first attempt (default 1).",
|
||||||
|
},
|
||||||
|
"delegated_scope": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Delegated scope metadata passed to nested subagent calls.",
|
||||||
|
},
|
||||||
|
"kept_work": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Kept work metadata passed to nested subagent calls.",
|
||||||
|
},
|
||||||
|
"execution_mode": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "inline or worker. Defaults to worker for JSONL/large batches, inline otherwise.",
|
||||||
|
},
|
||||||
|
"session_key": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional map run session key for persistence (default: default).",
|
||||||
|
},
|
||||||
|
"idempotency_key": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional key to deduplicate repeated run creation.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"task_template"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *AgenticMapTool) SetContext(channel, chatID string) {
|
||||||
|
t.originChannel = channel
|
||||||
|
t.originChatID = chatID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *AgenticMapTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
|
||||||
|
if t.manager == nil && t.runtime == nil {
|
||||||
|
return ErrorResult("agentic_map manager is not configured").WithError(fmt.Errorf("agentic_map manager is nil"))
|
||||||
|
}
|
||||||
|
|
||||||
|
items, usedJSONL, err := parseMapBoundaryItems(args)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult(err.Error()).WithError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
taskTemplate, ok := args["task_template"].(string)
|
||||||
|
if !ok || strings.TrimSpace(taskTemplate) == "" {
|
||||||
|
return ErrorResult("task_template is required").WithError(fmt.Errorf("task_template is required"))
|
||||||
|
}
|
||||||
|
if !strings.Contains(taskTemplate, "{{item_json}}") || !strings.Contains(taskTemplate, "{{index}}") {
|
||||||
|
return ErrorResult("task_template must include both {{item_json}} and {{index}} placeholders").
|
||||||
|
WithError(fmt.Errorf("task_template missing required placeholders"))
|
||||||
|
}
|
||||||
|
|
||||||
|
maxRetries, err := parseMapMaxRetries(args, 1)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult(err.Error()).WithError(err)
|
||||||
|
}
|
||||||
|
delegatedScope, err := parseOptionalStringArg(args, "delegated_scope")
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult(err.Error()).WithError(err)
|
||||||
|
}
|
||||||
|
keptWork, err := parseOptionalStringArg(args, "kept_work")
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult(err.Error()).WithError(err)
|
||||||
|
}
|
||||||
|
mode, err := resolveMapExecutionMode(args, len(items), usedJSONL)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult(err.Error()).WithError(err)
|
||||||
|
}
|
||||||
|
sessionKey, err := parseOptionalStringArg(args, "session_key")
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult(err.Error()).WithError(err)
|
||||||
|
}
|
||||||
|
idempotencyKey, err := parseOptionalStringArg(args, "idempotency_key")
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult(err.Error()).WithError(err)
|
||||||
|
}
|
||||||
|
if sessionKey == "" {
|
||||||
|
sessionKey = "default"
|
||||||
|
}
|
||||||
|
|
||||||
|
if t.runtime != nil {
|
||||||
|
run, reused, err := t.runtime.EnqueueRun(ctx, sessionKey, MapRunSpec{
|
||||||
|
OperatorKind: MapOperatorAgentic,
|
||||||
|
TaskTemplate: taskTemplate,
|
||||||
|
MaxRetries: uint16(maxRetries),
|
||||||
|
DelegatedScope: delegatedScope,
|
||||||
|
KeptWork: keptWork,
|
||||||
|
OriginChannel: t.originChannel,
|
||||||
|
OriginChatID: t.originChatID,
|
||||||
|
}, items, idempotencyKey)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult(fmt.Sprintf("failed to enqueue agentic_map run: %v", err)).WithError(err)
|
||||||
|
}
|
||||||
|
if mode == "worker" {
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"run_id": run.ID.String(),
|
||||||
|
"status": run.Status,
|
||||||
|
"accepted_count": len(items),
|
||||||
|
"queued_count": run.QueuedItems,
|
||||||
|
"execution_mode": "worker",
|
||||||
|
"idempotent_reuse": reused,
|
||||||
|
}
|
||||||
|
data, err := jsonv2.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult("failed to serialize agentic_map enqueue result").WithError(err)
|
||||||
|
}
|
||||||
|
return &ToolResult{
|
||||||
|
ForLLM: string(data),
|
||||||
|
ForUser: string(data),
|
||||||
|
Silent: false,
|
||||||
|
IsError: false,
|
||||||
|
Async: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
runFinal, err := t.runtime.ProcessRunToTerminal(ctx, run.ID, len(items)*(maxRetries+2)+32)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult(fmt.Sprintf("failed to complete inline agentic_map run %s: %v", run.ID.String(), err)).WithError(err)
|
||||||
|
}
|
||||||
|
itemRows, err := t.runtime.ReadRunItems(ctx, run.ID, 0, len(items))
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult(fmt.Sprintf("failed to read inline agentic_map results: %v", err)).WithError(err)
|
||||||
|
}
|
||||||
|
return buildAgenticMapInlineResult(runFinal, itemRows, maxRetries)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy synchronous path when runtime persistence is not configured.
|
||||||
|
if mode == "worker" {
|
||||||
|
return ErrorResult("worker mode requires map runtime persistence").WithError(fmt.Errorf("map runtime is nil"))
|
||||||
|
}
|
||||||
|
if t.manager == nil {
|
||||||
|
return ErrorResult("agentic_map manager is not configured").WithError(fmt.Errorf("agentic_map manager is nil"))
|
||||||
|
}
|
||||||
|
|
||||||
|
subTool := NewSubagentTool(t.manager)
|
||||||
|
subTool.SetContext(t.originChannel, t.originChatID)
|
||||||
|
|
||||||
|
type itemResult struct {
|
||||||
|
Index int `json:"index"`
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Attempts int `json:"attempts"`
|
||||||
|
Output string `json:"output,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
results := make([]itemResult, 0, len(items))
|
||||||
|
successCount := 0
|
||||||
|
|
||||||
|
for i, item := range items {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return ErrorResult("agentic_map cancelled before completion").WithError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
task := strings.ReplaceAll(taskTemplate, "{{item_json}}", item.ItemJSON)
|
||||||
|
task = strings.ReplaceAll(task, "{{index}}", strconv.Itoa(i))
|
||||||
|
|
||||||
|
attempts := 0
|
||||||
|
var lastErr string
|
||||||
|
var output string
|
||||||
|
okItem := false
|
||||||
|
for attempts <= maxRetries {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
lastErr = err.Error()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
attempts++
|
||||||
|
callArgs := map[string]interface{}{
|
||||||
|
"task": task,
|
||||||
|
"label": fmt.Sprintf("agentic-map-%d", i),
|
||||||
|
}
|
||||||
|
if delegatedScope != "" {
|
||||||
|
callArgs["delegated_scope"] = delegatedScope
|
||||||
|
}
|
||||||
|
if keptWork != "" {
|
||||||
|
callArgs["kept_work"] = keptWork
|
||||||
|
}
|
||||||
|
|
||||||
|
r := subTool.Execute(ctx, callArgs)
|
||||||
|
if r != nil && !r.IsError {
|
||||||
|
okItem = true
|
||||||
|
output = r.ForUser
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if r != nil {
|
||||||
|
lastErr = r.ForLLM
|
||||||
|
} else {
|
||||||
|
lastErr = "unknown subagent error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if okItem {
|
||||||
|
successCount++
|
||||||
|
}
|
||||||
|
results = append(results, itemResult{
|
||||||
|
Index: i,
|
||||||
|
Success: okItem,
|
||||||
|
Attempts: attempts,
|
||||||
|
Output: output,
|
||||||
|
Error: lastErr,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"count": len(results),
|
||||||
|
"summary": map[string]interface{}{
|
||||||
|
"success_count": successCount,
|
||||||
|
"failure_count": len(results) - successCount,
|
||||||
|
"max_retries": maxRetries,
|
||||||
|
},
|
||||||
|
"results": results,
|
||||||
|
}
|
||||||
|
data, err := jsonv2.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult("failed to serialize agentic_map results").WithError(err)
|
||||||
|
}
|
||||||
|
return &ToolResult{
|
||||||
|
ForLLM: string(data),
|
||||||
|
ForUser: string(data),
|
||||||
|
Silent: false,
|
||||||
|
IsError: false,
|
||||||
|
Async: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildAgenticMapInlineResult(run memsqlc.MapRun, items []MapItemProjection, maxRetries int) *ToolResult {
|
||||||
|
type itemResult struct {
|
||||||
|
Index int64 `json:"index"`
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Attempts int64 `json:"attempts"`
|
||||||
|
Output string `json:"output,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
results := make([]itemResult, 0, len(items))
|
||||||
|
successCount := 0
|
||||||
|
for _, item := range items {
|
||||||
|
outText := ""
|
||||||
|
switch out := item.Output.(type) {
|
||||||
|
case map[string]interface{}:
|
||||||
|
if forUser, ok := out["for_user"].(string); ok {
|
||||||
|
outText = forUser
|
||||||
|
}
|
||||||
|
case string:
|
||||||
|
outText = out
|
||||||
|
}
|
||||||
|
ok := item.Status == mapItemStatusSucceeded
|
||||||
|
if ok {
|
||||||
|
successCount++
|
||||||
|
}
|
||||||
|
results = append(results, itemResult{
|
||||||
|
Index: item.Index,
|
||||||
|
Success: ok,
|
||||||
|
Attempts: item.Attempts,
|
||||||
|
Output: outText,
|
||||||
|
Error: item.Error,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"run_id": run.ID.String(),
|
||||||
|
"status": run.Status,
|
||||||
|
"count": len(results),
|
||||||
|
"summary": map[string]interface{}{
|
||||||
|
"success_count": successCount,
|
||||||
|
"failure_count": len(results) - successCount,
|
||||||
|
"max_retries": maxRetries,
|
||||||
|
},
|
||||||
|
"results": results,
|
||||||
|
}
|
||||||
|
data, err := jsonv2.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult("failed to serialize agentic_map inline results").WithError(err)
|
||||||
|
}
|
||||||
|
return &ToolResult{
|
||||||
|
ForLLM: string(data),
|
||||||
|
ForUser: string(data),
|
||||||
|
Silent: false,
|
||||||
|
IsError: false,
|
||||||
|
Async: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
113
pkg/tools/agentic_map_test.go
Normal file
113
pkg/tools/agentic_map_test.go
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
jsonv2 "github.com/go-json-experiment/json"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/ZanzyTHEbar/dragonscale/pkg/bus"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAgenticMapTool_Execute_Success(t *testing.T) {
|
||||||
|
provider := &MockLanguageModel{}
|
||||||
|
manager := NewSubagentManager(provider, "test-model", "/tmp/test", bus.NewMessageBus())
|
||||||
|
manager.SetRunLoop(func(_ context.Context, _ ToolLoopConfig, _, userPrompt, _, _ string) (*ToolLoopResult, error) {
|
||||||
|
return &ToolLoopResult{Content: "processed: " + userPrompt, Iterations: 1}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
tool := NewAgenticMapTool(manager)
|
||||||
|
result := tool.Execute(context.Background(), map[string]interface{}{
|
||||||
|
"items": []interface{}{
|
||||||
|
map[string]interface{}{"name": "a"},
|
||||||
|
map[string]interface{}{"name": "b"},
|
||||||
|
},
|
||||||
|
"task_template": "Handle item {{index}} => {{item_json}}",
|
||||||
|
"max_retries": float64(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NotNil(t, result)
|
||||||
|
require.False(t, result.IsError, result.ForLLM)
|
||||||
|
|
||||||
|
var payload struct {
|
||||||
|
Count int `json:"count"`
|
||||||
|
Summary struct {
|
||||||
|
SuccessCount int `json:"success_count"`
|
||||||
|
FailureCount int `json:"failure_count"`
|
||||||
|
} `json:"summary"`
|
||||||
|
}
|
||||||
|
require.NoError(t, jsonv2.Unmarshal([]byte(result.ForLLM), &payload))
|
||||||
|
assert.Equal(t, 2, payload.Count)
|
||||||
|
assert.Equal(t, 2, payload.Summary.SuccessCount)
|
||||||
|
assert.Equal(t, 0, payload.Summary.FailureCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgenticMapTool_Execute_RetriesFailedItems(t *testing.T) {
|
||||||
|
provider := &MockLanguageModel{}
|
||||||
|
manager := NewSubagentManager(provider, "test-model", "/tmp/test", bus.NewMessageBus())
|
||||||
|
callCount := 0
|
||||||
|
manager.SetRunLoop(func(_ context.Context, _ ToolLoopConfig, _, userPrompt, _, _ string) (*ToolLoopResult, error) {
|
||||||
|
callCount++
|
||||||
|
if callCount == 1 {
|
||||||
|
return nil, fmt.Errorf("transient failure for %s", userPrompt)
|
||||||
|
}
|
||||||
|
return &ToolLoopResult{Content: "processed on retry", Iterations: 1}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
tool := NewAgenticMapTool(manager)
|
||||||
|
result := tool.Execute(context.Background(), map[string]interface{}{
|
||||||
|
"items": []interface{}{map[string]interface{}{"name": "retry-me"}},
|
||||||
|
"task_template": "Retry item {{index}} => {{item_json}}",
|
||||||
|
"max_retries": float64(2),
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NotNil(t, result)
|
||||||
|
require.False(t, result.IsError, result.ForLLM)
|
||||||
|
|
||||||
|
var payload struct {
|
||||||
|
Results []struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Attempts int `json:"attempts"`
|
||||||
|
} `json:"results"`
|
||||||
|
}
|
||||||
|
require.NoError(t, jsonv2.Unmarshal([]byte(result.ForLLM), &payload))
|
||||||
|
require.Len(t, payload.Results, 1)
|
||||||
|
assert.True(t, payload.Results[0].Success)
|
||||||
|
assert.Equal(t, 2, payload.Results[0].Attempts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgenticMapTool_Execute_RequiresPlaceholders(t *testing.T) {
|
||||||
|
provider := &MockLanguageModel{}
|
||||||
|
manager := NewSubagentManager(provider, "test-model", "/tmp/test", bus.NewMessageBus())
|
||||||
|
tool := NewAgenticMapTool(manager)
|
||||||
|
|
||||||
|
result := tool.Execute(context.Background(), map[string]interface{}{
|
||||||
|
"items": []interface{}{map[string]interface{}{"name": "x"}},
|
||||||
|
"task_template": "Handle item without placeholders",
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NotNil(t, result)
|
||||||
|
require.True(t, result.IsError)
|
||||||
|
assert.Contains(t, result.ForLLM, "placeholders")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgenticMapTool_Execute_ContextCancelled(t *testing.T) {
|
||||||
|
provider := &MockLanguageModel{}
|
||||||
|
manager := NewSubagentManager(provider, "test-model", "/tmp/test", bus.NewMessageBus())
|
||||||
|
tool := NewAgenticMapTool(manager)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
result := tool.Execute(ctx, map[string]interface{}{
|
||||||
|
"items": []interface{}{map[string]interface{}{"name": "x"}},
|
||||||
|
"task_template": "Handle {{index}} => {{item_json}}",
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NotNil(t, result)
|
||||||
|
require.True(t, result.IsError)
|
||||||
|
assert.Contains(t, result.ForLLM, "cancelled")
|
||||||
|
}
|
||||||
327
pkg/tools/llm_map.go
Normal file
327
pkg/tools/llm_map.go
Normal file
|
|
@ -0,0 +1,327 @@
|
||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
jsonv2 "github.com/go-json-experiment/json"
|
||||||
|
|
||||||
|
fantasy "charm.land/fantasy"
|
||||||
|
memsqlc "github.com/ZanzyTHEbar/dragonscale/pkg/memory/sqlc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LLMMapTool performs schema-validated per-item processing with model calls
|
||||||
|
// and no side effects (toolless, deterministic structure).
|
||||||
|
type LLMMapTool struct {
|
||||||
|
model fantasy.LanguageModel
|
||||||
|
modelID string
|
||||||
|
runtime *MapRuntime
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLLMMapTool(model fantasy.LanguageModel, modelID string) *LLMMapTool {
|
||||||
|
return &LLMMapTool{
|
||||||
|
model: model,
|
||||||
|
modelID: modelID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *LLMMapTool) SetRuntime(runtime *MapRuntime) {
|
||||||
|
t.runtime = runtime
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *LLMMapTool) Name() string {
|
||||||
|
return "llm_map"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *LLMMapTool) Description() string {
|
||||||
|
return "Apply an instruction to each item with schema-validated JSON output. Supports worker-backed runs with run handles for large batches."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *LLMMapTool) Parameters() map[string]interface{} {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"items": map[string]interface{}{
|
||||||
|
"type": "array",
|
||||||
|
"description": "Array of items to process.",
|
||||||
|
},
|
||||||
|
"input_jsonl": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Boundary-only JSONL input. Exactly one of items or input_jsonl is required.",
|
||||||
|
},
|
||||||
|
"instruction": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Instruction applied to each item. Output must be JSON object only.",
|
||||||
|
},
|
||||||
|
"output_schema": map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"description": "Optional lightweight JSON schema (properties + required) used to validate each output object.",
|
||||||
|
},
|
||||||
|
"execution_mode": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "inline or worker. Defaults to worker for JSONL/large batches, inline otherwise.",
|
||||||
|
},
|
||||||
|
"max_retries": map[string]interface{}{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Retries per item after first attempt when using worker execution (default 1, max 10).",
|
||||||
|
},
|
||||||
|
"session_key": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional map run session key for persistence (default: default).",
|
||||||
|
},
|
||||||
|
"idempotency_key": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional key to deduplicate repeated run creation.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"instruction"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *LLMMapTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
|
||||||
|
if t.model == nil && t.runtime == nil {
|
||||||
|
return ErrorResult("llm_map model is not configured").WithError(fmt.Errorf("llm_map model is nil"))
|
||||||
|
}
|
||||||
|
|
||||||
|
items, usedJSONL, err := parseMapBoundaryItems(args)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult(err.Error()).WithError(err)
|
||||||
|
}
|
||||||
|
instruction, ok := args["instruction"].(string)
|
||||||
|
if !ok || strings.TrimSpace(instruction) == "" {
|
||||||
|
return ErrorResult("instruction is required").WithError(fmt.Errorf("instruction is required"))
|
||||||
|
}
|
||||||
|
|
||||||
|
var schema map[string]interface{}
|
||||||
|
if rawSchema, ok := args["output_schema"].(map[string]interface{}); ok {
|
||||||
|
schema = rawSchema
|
||||||
|
}
|
||||||
|
schemaJSON := ""
|
||||||
|
if schema != nil {
|
||||||
|
b, err := canonicalJSONString(schema)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult("output_schema is not JSON-serializable").WithError(err)
|
||||||
|
}
|
||||||
|
schemaJSON = b
|
||||||
|
}
|
||||||
|
maxRetries, err := parseMapMaxRetries(args, 1)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult(err.Error()).WithError(err)
|
||||||
|
}
|
||||||
|
mode, err := resolveMapExecutionMode(args, len(items), usedJSONL)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult(err.Error()).WithError(err)
|
||||||
|
}
|
||||||
|
sessionKey, err := parseOptionalStringArg(args, "session_key")
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult(err.Error()).WithError(err)
|
||||||
|
}
|
||||||
|
idempotencyKey, err := parseOptionalStringArg(args, "idempotency_key")
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult(err.Error()).WithError(err)
|
||||||
|
}
|
||||||
|
if sessionKey == "" {
|
||||||
|
sessionKey = "default"
|
||||||
|
}
|
||||||
|
|
||||||
|
if t.runtime != nil {
|
||||||
|
run, reused, err := t.runtime.EnqueueRun(ctx, sessionKey, MapRunSpec{
|
||||||
|
OperatorKind: MapOperatorLLM,
|
||||||
|
Instruction: strings.TrimSpace(instruction),
|
||||||
|
OutputSchemaJSON: schemaJSON,
|
||||||
|
MaxRetries: uint16(maxRetries),
|
||||||
|
}, items, idempotencyKey)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult(fmt.Sprintf("failed to enqueue llm_map run: %v", err)).WithError(err)
|
||||||
|
}
|
||||||
|
if mode == "worker" {
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"run_id": run.ID.String(),
|
||||||
|
"status": run.Status,
|
||||||
|
"accepted_count": len(items),
|
||||||
|
"queued_count": run.QueuedItems,
|
||||||
|
"execution_mode": "worker",
|
||||||
|
"idempotent_reuse": reused,
|
||||||
|
}
|
||||||
|
data, err := jsonv2.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult("failed to serialize llm_map enqueue result").WithError(err)
|
||||||
|
}
|
||||||
|
return &ToolResult{
|
||||||
|
ForLLM: string(data),
|
||||||
|
ForUser: string(data),
|
||||||
|
Silent: false,
|
||||||
|
IsError: false,
|
||||||
|
Async: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inline mode still goes through the worker lifecycle for a single runtime path.
|
||||||
|
runFinal, err := t.runtime.ProcessRunToTerminal(ctx, run.ID, len(items)*(maxRetries+2)+32)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult(fmt.Sprintf("failed to complete inline llm_map run %s: %v", run.ID.String(), err)).WithError(err)
|
||||||
|
}
|
||||||
|
itemRows, err := t.runtime.ReadRunItems(ctx, run.ID, 0, len(items))
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult(fmt.Sprintf("failed to read inline llm_map results: %v", err)).WithError(err)
|
||||||
|
}
|
||||||
|
return buildLLMMapInlineResult(runFinal, itemRows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy synchronous path when runtime persistence is not configured.
|
||||||
|
if mode == "worker" {
|
||||||
|
return ErrorResult("worker mode requires map runtime persistence").WithError(fmt.Errorf("map runtime is nil"))
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]map[string]interface{}, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
prompt := fmt.Sprintf(
|
||||||
|
"Instruction:\n%s\n\nItem JSON:\n%s\n\nReturn exactly one JSON object and nothing else.",
|
||||||
|
instruction,
|
||||||
|
item.ItemJSON,
|
||||||
|
)
|
||||||
|
maxOut := int64(512)
|
||||||
|
call := fantasy.Call{
|
||||||
|
Prompt: fantasy.Prompt{
|
||||||
|
fantasy.NewSystemMessage("You are a pure mapper. Return only strict JSON object output."),
|
||||||
|
fantasy.NewUserMessage(prompt),
|
||||||
|
},
|
||||||
|
MaxOutputTokens: &maxOut,
|
||||||
|
}
|
||||||
|
resp, err := t.model.Generate(ctx, call)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult(fmt.Sprintf("llm_map failed at index %d", item.Index)).WithError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var out map[string]interface{}
|
||||||
|
if err := jsonv2.Unmarshal([]byte(resp.Content.Text()), &out); err != nil {
|
||||||
|
return ErrorResult(fmt.Sprintf("llm_map output at index %d is not valid JSON object", item.Index)).WithError(err)
|
||||||
|
}
|
||||||
|
if schema != nil {
|
||||||
|
if err := validateLLMMapOutputSchema(out, schema); err != nil {
|
||||||
|
return ErrorResult(fmt.Sprintf("llm_map schema validation failed at index %d: %v", item.Index, err)).WithError(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
results = append(results, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"count": len(results),
|
||||||
|
"results": results,
|
||||||
|
}
|
||||||
|
data, err := jsonv2.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult("failed to serialize llm_map results").WithError(err)
|
||||||
|
}
|
||||||
|
return &ToolResult{
|
||||||
|
ForLLM: string(data),
|
||||||
|
ForUser: string(data),
|
||||||
|
Silent: false,
|
||||||
|
IsError: false,
|
||||||
|
Async: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildLLMMapInlineResult(run memsqlc.MapRun, items []MapItemProjection) *ToolResult {
|
||||||
|
results := make([]map[string]interface{}, 0, len(items))
|
||||||
|
failures := make([]map[string]interface{}, 0)
|
||||||
|
for _, item := range items {
|
||||||
|
if item.Status == mapItemStatusSucceeded {
|
||||||
|
outMap, ok := item.Output.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
outMap = map[string]interface{}{
|
||||||
|
"value": item.Output,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
results = append(results, outMap)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
failures = append(failures, map[string]interface{}{
|
||||||
|
"index": item.Index,
|
||||||
|
"status": item.Status,
|
||||||
|
"attempts": item.Attempts,
|
||||||
|
"error": item.Error,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"run_id": run.ID.String(),
|
||||||
|
"status": run.Status,
|
||||||
|
"count": len(results),
|
||||||
|
"summary": map[string]interface{}{
|
||||||
|
"success_count": len(results),
|
||||||
|
"failure_count": len(failures),
|
||||||
|
},
|
||||||
|
"results": results,
|
||||||
|
"failures": failures,
|
||||||
|
}
|
||||||
|
data, err := jsonv2.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult("failed to serialize llm_map inline results").WithError(err)
|
||||||
|
}
|
||||||
|
return &ToolResult{
|
||||||
|
ForLLM: string(data),
|
||||||
|
ForUser: string(data),
|
||||||
|
Silent: false,
|
||||||
|
IsError: false,
|
||||||
|
Async: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateLLMMapOutputSchema(out map[string]interface{}, schema map[string]interface{}) error {
|
||||||
|
if requiredRaw, ok := schema["required"].([]interface{}); ok {
|
||||||
|
for _, r := range requiredRaw {
|
||||||
|
key, _ := r.(string)
|
||||||
|
if key == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, exists := out[key]; !exists {
|
||||||
|
return fmt.Errorf("missing required field %q", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
props, _ := schema["properties"].(map[string]interface{})
|
||||||
|
for key, defRaw := range props {
|
||||||
|
value, exists := out[key]
|
||||||
|
if !exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
def, _ := defRaw.(map[string]interface{})
|
||||||
|
wantType, _ := def["type"].(string)
|
||||||
|
if wantType == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !matchesJSONType(value, wantType) {
|
||||||
|
return fmt.Errorf("field %q expected type %q", key, wantType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchesJSONType(value interface{}, want string) bool {
|
||||||
|
switch want {
|
||||||
|
case "string":
|
||||||
|
_, ok := value.(string)
|
||||||
|
return ok
|
||||||
|
case "number":
|
||||||
|
_, ok := value.(float64)
|
||||||
|
return ok
|
||||||
|
case "integer":
|
||||||
|
f, ok := value.(float64)
|
||||||
|
return ok && f == float64(int64(f))
|
||||||
|
case "boolean":
|
||||||
|
_, ok := value.(bool)
|
||||||
|
return ok
|
||||||
|
case "object":
|
||||||
|
_, ok := value.(map[string]interface{})
|
||||||
|
return ok
|
||||||
|
case "array":
|
||||||
|
_, ok := value.([]interface{})
|
||||||
|
return ok
|
||||||
|
case "null":
|
||||||
|
return value == nil
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
111
pkg/tools/llm_map_test.go
Normal file
111
pkg/tools/llm_map_test.go
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
jsonv2 "github.com/go-json-experiment/json"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
fantasy "charm.land/fantasy"
|
||||||
|
)
|
||||||
|
|
||||||
|
type llmMapMockModel struct {
|
||||||
|
responses []string
|
||||||
|
next int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *llmMapMockModel) Generate(_ context.Context, _ fantasy.Call) (*fantasy.Response, error) {
|
||||||
|
if len(m.responses) == 0 {
|
||||||
|
return nil, fmt.Errorf("no responses configured")
|
||||||
|
}
|
||||||
|
resp := m.responses[m.next%len(m.responses)]
|
||||||
|
m.next++
|
||||||
|
return &fantasy.Response{
|
||||||
|
Content: fantasy.ResponseContent{fantasy.TextContent{Text: resp}},
|
||||||
|
FinishReason: fantasy.FinishReasonStop,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *llmMapMockModel) Stream(ctx context.Context, call fantasy.Call) (fantasy.StreamResponse, error) {
|
||||||
|
resp, err := m.Generate(ctx, call)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return func(yield func(fantasy.StreamPart) bool) {
|
||||||
|
if !yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeTextDelta, Delta: resp.Content.Text()}) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeFinish, FinishReason: fantasy.FinishReasonStop})
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *llmMapMockModel) GenerateObject(_ context.Context, _ fantasy.ObjectCall) (*fantasy.ObjectResponse, error) {
|
||||||
|
return nil, fmt.Errorf("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *llmMapMockModel) StreamObject(_ context.Context, _ fantasy.ObjectCall) (fantasy.ObjectStreamResponse, error) {
|
||||||
|
return nil, fmt.Errorf("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *llmMapMockModel) Provider() string { return "mock" }
|
||||||
|
func (m *llmMapMockModel) Model() string { return "mock-llm-map" }
|
||||||
|
|
||||||
|
func TestLLMMapTool_Execute_Success(t *testing.T) {
|
||||||
|
model := &llmMapMockModel{
|
||||||
|
responses: []string{
|
||||||
|
`{"label":"alpha","priority":1}`,
|
||||||
|
`{"label":"beta","priority":2}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
tool := NewLLMMapTool(model, "mock-llm-map")
|
||||||
|
|
||||||
|
result := tool.Execute(context.Background(), map[string]interface{}{
|
||||||
|
"instruction": "Convert item to {label, priority}",
|
||||||
|
"items": []interface{}{
|
||||||
|
map[string]interface{}{"name": "A"},
|
||||||
|
map[string]interface{}{"name": "B"},
|
||||||
|
},
|
||||||
|
"output_schema": map[string]interface{}{
|
||||||
|
"required": []interface{}{"label", "priority"},
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"label": map[string]interface{}{"type": "string"},
|
||||||
|
"priority": map[string]interface{}{"type": "integer"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
require.NotNil(t, result)
|
||||||
|
require.False(t, result.IsError, result.ForLLM)
|
||||||
|
|
||||||
|
var payload struct {
|
||||||
|
Count int `json:"count"`
|
||||||
|
Results []map[string]interface{} `json:"results"`
|
||||||
|
}
|
||||||
|
require.NoError(t, jsonv2.Unmarshal([]byte(result.ForLLM), &payload))
|
||||||
|
assert.Equal(t, 2, payload.Count)
|
||||||
|
require.Len(t, payload.Results, 2)
|
||||||
|
assert.Equal(t, "alpha", payload.Results[0]["label"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLLMMapTool_Execute_SchemaValidationFailure(t *testing.T) {
|
||||||
|
model := &llmMapMockModel{
|
||||||
|
responses: []string{`{"only":"value"}`},
|
||||||
|
}
|
||||||
|
tool := NewLLMMapTool(model, "mock-llm-map")
|
||||||
|
|
||||||
|
result := tool.Execute(context.Background(), map[string]interface{}{
|
||||||
|
"instruction": "Return normalized object",
|
||||||
|
"items": []interface{}{map[string]interface{}{"name": "x"}},
|
||||||
|
"output_schema": map[string]interface{}{
|
||||||
|
"required": []interface{}{"label"},
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"label": map[string]interface{}{"type": "string"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
require.NotNil(t, result)
|
||||||
|
require.True(t, result.IsError)
|
||||||
|
assert.Contains(t, result.ForLLM, "schema validation failed")
|
||||||
|
}
|
||||||
204
pkg/tools/map_boundary.go
Normal file
204
pkg/tools/map_boundary.go
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
jsonv2 "github.com/go-json-experiment/json"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultMapInlineCutoff = 12
|
||||||
|
defaultMapReadLimit = 100
|
||||||
|
maxMapReadLimit = 500
|
||||||
|
maxMapItems = 5000
|
||||||
|
)
|
||||||
|
|
||||||
|
type mapBoundaryItem struct {
|
||||||
|
Index int
|
||||||
|
ItemJSON string
|
||||||
|
InputHash string
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseMapBoundaryItems(args map[string]interface{}) ([]mapBoundaryItem, bool, error) {
|
||||||
|
itemsRaw, hasItems := args["items"]
|
||||||
|
inputJSONLRaw, hasJSONL := args["input_jsonl"]
|
||||||
|
if hasItems == hasJSONL {
|
||||||
|
return nil, false, fmt.Errorf("provide exactly one of items or input_jsonl")
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]mapBoundaryItem, 0)
|
||||||
|
if hasItems {
|
||||||
|
array, ok := itemsRaw.([]interface{})
|
||||||
|
if !ok {
|
||||||
|
return nil, false, fmt.Errorf("items must be an array")
|
||||||
|
}
|
||||||
|
if len(array) == 0 {
|
||||||
|
return nil, false, fmt.Errorf("items array is empty")
|
||||||
|
}
|
||||||
|
if len(array) > maxMapItems {
|
||||||
|
return nil, false, fmt.Errorf("items array exceeds max size %d", maxMapItems)
|
||||||
|
}
|
||||||
|
for idx, item := range array {
|
||||||
|
itemJSON, err := canonicalJSONString(item)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, fmt.Errorf("serialize item at index %d: %w", idx, err)
|
||||||
|
}
|
||||||
|
items = append(items, mapBoundaryItem{
|
||||||
|
Index: idx,
|
||||||
|
ItemJSON: itemJSON,
|
||||||
|
InputHash: hashString(itemJSON),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return items, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
inputJSONL, ok := inputJSONLRaw.(string)
|
||||||
|
if !ok || strings.TrimSpace(inputJSONL) == "" {
|
||||||
|
return nil, true, fmt.Errorf("input_jsonl must be a non-empty string")
|
||||||
|
}
|
||||||
|
scanner := bufio.NewScanner(strings.NewReader(inputJSONL))
|
||||||
|
// Allow large records while still preventing unbounded memory growth.
|
||||||
|
const maxJSONLLineBytes = 4 * 1024 * 1024
|
||||||
|
scanner.Buffer(make([]byte, 64*1024), maxJSONLLineBytes)
|
||||||
|
|
||||||
|
lineNo := 0
|
||||||
|
for scanner.Scan() {
|
||||||
|
lineNo++
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var item interface{}
|
||||||
|
if err := jsonv2.Unmarshal([]byte(line), &item); err != nil {
|
||||||
|
return nil, true, fmt.Errorf("invalid JSONL at line %d: %w", lineNo, err)
|
||||||
|
}
|
||||||
|
itemJSON, err := canonicalJSONString(item)
|
||||||
|
if err != nil {
|
||||||
|
return nil, true, fmt.Errorf("canonicalize JSONL item at line %d: %w", lineNo, err)
|
||||||
|
}
|
||||||
|
items = append(items, mapBoundaryItem{
|
||||||
|
Index: len(items),
|
||||||
|
ItemJSON: itemJSON,
|
||||||
|
InputHash: hashString(itemJSON),
|
||||||
|
})
|
||||||
|
if len(items) > maxMapItems {
|
||||||
|
return nil, true, fmt.Errorf("input_jsonl exceeds max item count %d", maxMapItems)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return nil, true, fmt.Errorf("read input_jsonl: %w", err)
|
||||||
|
}
|
||||||
|
if len(items) == 0 {
|
||||||
|
return nil, true, fmt.Errorf("input_jsonl contains no records")
|
||||||
|
}
|
||||||
|
return items, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveMapExecutionMode(args map[string]interface{}, itemCount int, usedJSONL bool) (string, error) {
|
||||||
|
if raw, ok := args["execution_mode"]; ok {
|
||||||
|
mode, ok := raw.(string)
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("execution_mode must be a string")
|
||||||
|
}
|
||||||
|
switch strings.ToLower(strings.TrimSpace(mode)) {
|
||||||
|
case "inline":
|
||||||
|
return "inline", nil
|
||||||
|
case "worker":
|
||||||
|
return "worker", nil
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("execution_mode must be one of inline|worker")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if usedJSONL || itemCount > defaultMapInlineCutoff {
|
||||||
|
return "worker", nil
|
||||||
|
}
|
||||||
|
return "inline", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseMapReadFormat(args map[string]interface{}) (string, error) {
|
||||||
|
raw, ok := args["format"]
|
||||||
|
if !ok {
|
||||||
|
return "json", nil
|
||||||
|
}
|
||||||
|
format, ok := raw.(string)
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("format must be a string")
|
||||||
|
}
|
||||||
|
switch strings.ToLower(strings.TrimSpace(format)) {
|
||||||
|
case "json", "":
|
||||||
|
return "json", nil
|
||||||
|
case "jsonl":
|
||||||
|
return "jsonl", nil
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("format must be json or jsonl")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseMapLimit(args map[string]interface{}, key string, defaultValue, maxValue int) (int, error) {
|
||||||
|
value, ok := args[key]
|
||||||
|
if !ok {
|
||||||
|
return defaultValue, nil
|
||||||
|
}
|
||||||
|
switch v := value.(type) {
|
||||||
|
case float64:
|
||||||
|
if v < 0 {
|
||||||
|
return 0, fmt.Errorf("%s must be >= 0", key)
|
||||||
|
}
|
||||||
|
out := int(v)
|
||||||
|
if out > maxValue {
|
||||||
|
out = maxValue
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
case int:
|
||||||
|
if v < 0 {
|
||||||
|
return 0, fmt.Errorf("%s must be >= 0", key)
|
||||||
|
}
|
||||||
|
if v > maxValue {
|
||||||
|
return maxValue, nil
|
||||||
|
}
|
||||||
|
return v, nil
|
||||||
|
default:
|
||||||
|
return 0, fmt.Errorf("%s must be numeric", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseMapMaxRetries(args map[string]interface{}, defaultValue int) (int, error) {
|
||||||
|
raw, ok := args["max_retries"]
|
||||||
|
if !ok {
|
||||||
|
return defaultValue, nil
|
||||||
|
}
|
||||||
|
value, err := parseMapLimit(map[string]interface{}{"value": raw}, "value", defaultValue, 10)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("max_retries %w", err)
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseOptionalStringArg(args map[string]interface{}, key string) (string, error) {
|
||||||
|
raw, ok := args[key]
|
||||||
|
if !ok {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
value, ok := raw.(string)
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("%s must be a string", key)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(value), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func canonicalJSONString(v interface{}) (string, error) {
|
||||||
|
b, err := jsonv2.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hashString(value string) string {
|
||||||
|
sum := sha256.Sum256([]byte(value))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
230
pkg/tools/map_flatbuffer_codec.go
Normal file
230
pkg/tools/map_flatbuffer_codec.go
Normal file
|
|
@ -0,0 +1,230 @@
|
||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/ZanzyTHEbar/dragonscale/pkg/tools/mapopsfb"
|
||||||
|
flatbuffers "github.com/google/flatbuffers/go"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MapOperatorKind string
|
||||||
|
|
||||||
|
const (
|
||||||
|
MapOperatorLLM MapOperatorKind = "llm_map"
|
||||||
|
MapOperatorAgentic MapOperatorKind = "agentic_map"
|
||||||
|
mapPayloadVersion uint16 = 1
|
||||||
|
)
|
||||||
|
|
||||||
|
type MapRunSpec struct {
|
||||||
|
Version uint16
|
||||||
|
OperatorKind MapOperatorKind
|
||||||
|
Instruction string
|
||||||
|
TaskTemplate string
|
||||||
|
OutputSchemaJSON string
|
||||||
|
MaxRetries uint16
|
||||||
|
DelegatedScope string
|
||||||
|
KeptWork string
|
||||||
|
OriginChannel string
|
||||||
|
OriginChatID string
|
||||||
|
}
|
||||||
|
|
||||||
|
type MapItemInputRecord struct {
|
||||||
|
Version uint16
|
||||||
|
ItemIndex uint32
|
||||||
|
ItemJSON string
|
||||||
|
InputHash string
|
||||||
|
}
|
||||||
|
|
||||||
|
type MapItemOutputRecord struct {
|
||||||
|
Version uint16
|
||||||
|
ItemIndex uint32
|
||||||
|
Success bool
|
||||||
|
Attempts uint16
|
||||||
|
OutputJSON string
|
||||||
|
ErrorText string
|
||||||
|
OutputHash string
|
||||||
|
}
|
||||||
|
|
||||||
|
func EncodeMapRunSpecFlatBuffer(spec MapRunSpec) ([]byte, error) {
|
||||||
|
if spec.OperatorKind == "" {
|
||||||
|
return nil, fmt.Errorf("operator kind is required")
|
||||||
|
}
|
||||||
|
version := spec.Version
|
||||||
|
if version == 0 {
|
||||||
|
version = 1
|
||||||
|
}
|
||||||
|
maxRetries := spec.MaxRetries
|
||||||
|
if maxRetries == 0 {
|
||||||
|
maxRetries = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
b := flatbuffers.NewBuilder(256)
|
||||||
|
operatorKind := b.CreateString(string(spec.OperatorKind))
|
||||||
|
instruction := createOptionalFBString(b, spec.Instruction)
|
||||||
|
taskTemplate := createOptionalFBString(b, spec.TaskTemplate)
|
||||||
|
outputSchemaJSON := createOptionalFBString(b, spec.OutputSchemaJSON)
|
||||||
|
delegatedScope := createOptionalFBString(b, spec.DelegatedScope)
|
||||||
|
keptWork := createOptionalFBString(b, spec.KeptWork)
|
||||||
|
originChannel := createOptionalFBString(b, spec.OriginChannel)
|
||||||
|
originChatID := createOptionalFBString(b, spec.OriginChatID)
|
||||||
|
|
||||||
|
mapopsfb.MapRunSpecStart(b)
|
||||||
|
mapopsfb.MapRunSpecAddVersion(b, version)
|
||||||
|
mapopsfb.MapRunSpecAddOperatorKind(b, operatorKind)
|
||||||
|
if instruction != 0 {
|
||||||
|
mapopsfb.MapRunSpecAddInstruction(b, instruction)
|
||||||
|
}
|
||||||
|
if taskTemplate != 0 {
|
||||||
|
mapopsfb.MapRunSpecAddTaskTemplate(b, taskTemplate)
|
||||||
|
}
|
||||||
|
if outputSchemaJSON != 0 {
|
||||||
|
mapopsfb.MapRunSpecAddOutputSchemaJson(b, outputSchemaJSON)
|
||||||
|
}
|
||||||
|
mapopsfb.MapRunSpecAddMaxRetries(b, maxRetries)
|
||||||
|
if delegatedScope != 0 {
|
||||||
|
mapopsfb.MapRunSpecAddDelegatedScope(b, delegatedScope)
|
||||||
|
}
|
||||||
|
if keptWork != 0 {
|
||||||
|
mapopsfb.MapRunSpecAddKeptWork(b, keptWork)
|
||||||
|
}
|
||||||
|
if originChannel != 0 {
|
||||||
|
mapopsfb.MapRunSpecAddOriginChannel(b, originChannel)
|
||||||
|
}
|
||||||
|
if originChatID != 0 {
|
||||||
|
mapopsfb.MapRunSpecAddOriginChatId(b, originChatID)
|
||||||
|
}
|
||||||
|
obj := mapopsfb.MapRunSpecEnd(b)
|
||||||
|
b.Finish(obj)
|
||||||
|
|
||||||
|
return b.FinishedBytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func DecodeMapRunSpecFlatBuffer(buf []byte) (MapRunSpec, error) {
|
||||||
|
if len(buf) < flatbuffers.SizeUint32 {
|
||||||
|
return MapRunSpec{}, fmt.Errorf("flatbuffer payload too short")
|
||||||
|
}
|
||||||
|
specFB := mapopsfb.GetRootAsMapRunSpec(buf, 0)
|
||||||
|
version := specFB.Version()
|
||||||
|
if version != mapPayloadVersion {
|
||||||
|
return MapRunSpec{}, fmt.Errorf("unsupported map run spec version %d", version)
|
||||||
|
}
|
||||||
|
spec := MapRunSpec{
|
||||||
|
Version: version,
|
||||||
|
OperatorKind: MapOperatorKind(string(specFB.OperatorKind())),
|
||||||
|
Instruction: string(specFB.Instruction()),
|
||||||
|
TaskTemplate: string(specFB.TaskTemplate()),
|
||||||
|
OutputSchemaJSON: string(specFB.OutputSchemaJson()),
|
||||||
|
MaxRetries: specFB.MaxRetries(),
|
||||||
|
DelegatedScope: string(specFB.DelegatedScope()),
|
||||||
|
KeptWork: string(specFB.KeptWork()),
|
||||||
|
OriginChannel: string(specFB.OriginChannel()),
|
||||||
|
OriginChatID: string(specFB.OriginChatId()),
|
||||||
|
}
|
||||||
|
if spec.OperatorKind == "" {
|
||||||
|
return MapRunSpec{}, fmt.Errorf("invalid map run spec: operator kind is empty")
|
||||||
|
}
|
||||||
|
return spec, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func EncodeMapItemInputFlatBuffer(rec MapItemInputRecord) ([]byte, error) {
|
||||||
|
if rec.ItemJSON == "" {
|
||||||
|
return nil, fmt.Errorf("item JSON is required")
|
||||||
|
}
|
||||||
|
version := rec.Version
|
||||||
|
if version == 0 {
|
||||||
|
version = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
b := flatbuffers.NewBuilder(128)
|
||||||
|
itemJSON := b.CreateString(rec.ItemJSON)
|
||||||
|
inputHash := createOptionalFBString(b, rec.InputHash)
|
||||||
|
|
||||||
|
mapopsfb.MapItemInputStart(b)
|
||||||
|
mapopsfb.MapItemInputAddVersion(b, version)
|
||||||
|
mapopsfb.MapItemInputAddItemIndex(b, rec.ItemIndex)
|
||||||
|
mapopsfb.MapItemInputAddItemJson(b, itemJSON)
|
||||||
|
if inputHash != 0 {
|
||||||
|
mapopsfb.MapItemInputAddInputHash(b, inputHash)
|
||||||
|
}
|
||||||
|
obj := mapopsfb.MapItemInputEnd(b)
|
||||||
|
b.Finish(obj)
|
||||||
|
return b.FinishedBytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func DecodeMapItemInputFlatBuffer(buf []byte) (MapItemInputRecord, error) {
|
||||||
|
if len(buf) < flatbuffers.SizeUint32 {
|
||||||
|
return MapItemInputRecord{}, fmt.Errorf("flatbuffer payload too short")
|
||||||
|
}
|
||||||
|
recFB := mapopsfb.GetRootAsMapItemInput(buf, 0)
|
||||||
|
version := recFB.Version()
|
||||||
|
if version != mapPayloadVersion {
|
||||||
|
return MapItemInputRecord{}, fmt.Errorf("unsupported map item input version %d", version)
|
||||||
|
}
|
||||||
|
rec := MapItemInputRecord{
|
||||||
|
Version: version,
|
||||||
|
ItemIndex: recFB.ItemIndex(),
|
||||||
|
ItemJSON: string(recFB.ItemJson()),
|
||||||
|
InputHash: string(recFB.InputHash()),
|
||||||
|
}
|
||||||
|
if rec.ItemJSON == "" {
|
||||||
|
return MapItemInputRecord{}, fmt.Errorf("invalid map item input: item JSON is empty")
|
||||||
|
}
|
||||||
|
return rec, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func EncodeMapItemOutputFlatBuffer(rec MapItemOutputRecord) ([]byte, error) {
|
||||||
|
version := rec.Version
|
||||||
|
if version == 0 {
|
||||||
|
version = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
b := flatbuffers.NewBuilder(128)
|
||||||
|
outputJSON := createOptionalFBString(b, rec.OutputJSON)
|
||||||
|
errorText := createOptionalFBString(b, rec.ErrorText)
|
||||||
|
outputHash := createOptionalFBString(b, rec.OutputHash)
|
||||||
|
|
||||||
|
mapopsfb.MapItemOutputStart(b)
|
||||||
|
mapopsfb.MapItemOutputAddVersion(b, version)
|
||||||
|
mapopsfb.MapItemOutputAddItemIndex(b, rec.ItemIndex)
|
||||||
|
mapopsfb.MapItemOutputAddSuccess(b, rec.Success)
|
||||||
|
mapopsfb.MapItemOutputAddAttempts(b, rec.Attempts)
|
||||||
|
if outputJSON != 0 {
|
||||||
|
mapopsfb.MapItemOutputAddOutputJson(b, outputJSON)
|
||||||
|
}
|
||||||
|
if errorText != 0 {
|
||||||
|
mapopsfb.MapItemOutputAddErrorText(b, errorText)
|
||||||
|
}
|
||||||
|
if outputHash != 0 {
|
||||||
|
mapopsfb.MapItemOutputAddOutputHash(b, outputHash)
|
||||||
|
}
|
||||||
|
obj := mapopsfb.MapItemOutputEnd(b)
|
||||||
|
b.Finish(obj)
|
||||||
|
return b.FinishedBytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func DecodeMapItemOutputFlatBuffer(buf []byte) (MapItemOutputRecord, error) {
|
||||||
|
if len(buf) < flatbuffers.SizeUint32 {
|
||||||
|
return MapItemOutputRecord{}, fmt.Errorf("flatbuffer payload too short")
|
||||||
|
}
|
||||||
|
recFB := mapopsfb.GetRootAsMapItemOutput(buf, 0)
|
||||||
|
version := recFB.Version()
|
||||||
|
if version != mapPayloadVersion {
|
||||||
|
return MapItemOutputRecord{}, fmt.Errorf("unsupported map item output version %d", version)
|
||||||
|
}
|
||||||
|
return MapItemOutputRecord{
|
||||||
|
Version: version,
|
||||||
|
ItemIndex: recFB.ItemIndex(),
|
||||||
|
Success: recFB.Success(),
|
||||||
|
Attempts: recFB.Attempts(),
|
||||||
|
OutputJSON: string(recFB.OutputJson()),
|
||||||
|
ErrorText: string(recFB.ErrorText()),
|
||||||
|
OutputHash: string(recFB.OutputHash()),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func createOptionalFBString(b *flatbuffers.Builder, value string) flatbuffers.UOffsetT {
|
||||||
|
if value == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return b.CreateString(value)
|
||||||
|
}
|
||||||
83
pkg/tools/map_flatbuffer_codec_test.go
Normal file
83
pkg/tools/map_flatbuffer_codec_test.go
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMapRunSpecFlatBuffer_RoundTrip(t *testing.T) {
|
||||||
|
original := MapRunSpec{
|
||||||
|
Version: 1,
|
||||||
|
OperatorKind: MapOperatorLLM,
|
||||||
|
Instruction: "extract label and priority",
|
||||||
|
TaskTemplate: "",
|
||||||
|
OutputSchemaJSON: `{"type":"object"}`,
|
||||||
|
MaxRetries: 3,
|
||||||
|
DelegatedScope: "",
|
||||||
|
KeptWork: "",
|
||||||
|
OriginChannel: "cli",
|
||||||
|
OriginChatID: "direct",
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := EncodeMapRunSpecFlatBuffer(original)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encode run spec: %v", err)
|
||||||
|
}
|
||||||
|
decoded, err := DecodeMapRunSpecFlatBuffer(data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode run spec: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if decoded.OperatorKind != original.OperatorKind {
|
||||||
|
t.Fatalf("operator kind mismatch: got %q want %q", decoded.OperatorKind, original.OperatorKind)
|
||||||
|
}
|
||||||
|
if decoded.Instruction != original.Instruction {
|
||||||
|
t.Fatalf("instruction mismatch: got %q want %q", decoded.Instruction, original.Instruction)
|
||||||
|
}
|
||||||
|
if decoded.OutputSchemaJSON != original.OutputSchemaJSON {
|
||||||
|
t.Fatalf("schema mismatch: got %q want %q", decoded.OutputSchemaJSON, original.OutputSchemaJSON)
|
||||||
|
}
|
||||||
|
if decoded.MaxRetries != original.MaxRetries {
|
||||||
|
t.Fatalf("max retries mismatch: got %d want %d", decoded.MaxRetries, original.MaxRetries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMapItemFlatBuffer_RoundTrip(t *testing.T) {
|
||||||
|
input := MapItemInputRecord{
|
||||||
|
Version: 1,
|
||||||
|
ItemIndex: 7,
|
||||||
|
ItemJSON: `{"name":"alpha","priority":1}`,
|
||||||
|
InputHash: "abc123",
|
||||||
|
}
|
||||||
|
inBuf, err := EncodeMapItemInputFlatBuffer(input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encode input: %v", err)
|
||||||
|
}
|
||||||
|
inDecoded, err := DecodeMapItemInputFlatBuffer(inBuf)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode input: %v", err)
|
||||||
|
}
|
||||||
|
if inDecoded.ItemIndex != input.ItemIndex || inDecoded.ItemJSON != input.ItemJSON || inDecoded.InputHash != input.InputHash {
|
||||||
|
t.Fatalf("input mismatch: got %+v want %+v", inDecoded, input)
|
||||||
|
}
|
||||||
|
|
||||||
|
output := MapItemOutputRecord{
|
||||||
|
Version: 1,
|
||||||
|
ItemIndex: 7,
|
||||||
|
Success: true,
|
||||||
|
Attempts: 2,
|
||||||
|
OutputJSON: `{"label":"alpha"}`,
|
||||||
|
ErrorText: "",
|
||||||
|
OutputHash: "def456",
|
||||||
|
}
|
||||||
|
outBuf, err := EncodeMapItemOutputFlatBuffer(output)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encode output: %v", err)
|
||||||
|
}
|
||||||
|
outDecoded, err := DecodeMapItemOutputFlatBuffer(outBuf)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode output: %v", err)
|
||||||
|
}
|
||||||
|
if outDecoded.ItemIndex != output.ItemIndex || outDecoded.Success != output.Success || outDecoded.Attempts != output.Attempts || outDecoded.OutputJSON != output.OutputJSON {
|
||||||
|
t.Fatalf("output mismatch: got %+v want %+v", outDecoded, output)
|
||||||
|
}
|
||||||
|
}
|
||||||
33
pkg/tools/map_payloads.fbs
Normal file
33
pkg/tools/map_payloads.fbs
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
// FlatBuffers contract for map operator persistence payloads.
|
||||||
|
// Boundary JSON/JSONL is converted to these binary records before DB persistence.
|
||||||
|
namespace mapopsfb;
|
||||||
|
|
||||||
|
table MapRunSpec {
|
||||||
|
version: uint16 = 1;
|
||||||
|
operator_kind: string (required); // llm_map | agentic_map
|
||||||
|
instruction: string;
|
||||||
|
task_template: string;
|
||||||
|
output_schema_json: string;
|
||||||
|
max_retries: uint16 = 1;
|
||||||
|
delegated_scope: string;
|
||||||
|
kept_work: string;
|
||||||
|
origin_channel: string;
|
||||||
|
origin_chat_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
table MapItemInput {
|
||||||
|
version: uint16 = 1;
|
||||||
|
item_index: uint32;
|
||||||
|
item_json: string (required); // canonical boundary JSON object
|
||||||
|
input_hash: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
table MapItemOutput {
|
||||||
|
version: uint16 = 1;
|
||||||
|
item_index: uint32;
|
||||||
|
success: bool = false;
|
||||||
|
attempts: uint16 = 0;
|
||||||
|
output_json: string;
|
||||||
|
error_text: string;
|
||||||
|
output_hash: string;
|
||||||
|
}
|
||||||
216
pkg/tools/map_run_tools.go
Normal file
216
pkg/tools/map_run_tools.go
Normal file
|
|
@ -0,0 +1,216 @@
|
||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/ZanzyTHEbar/dragonscale/pkg/ids"
|
||||||
|
jsonv2 "github.com/go-json-experiment/json"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MapRunStatusTool struct {
|
||||||
|
runtime *MapRuntime
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMapRunStatusTool(runtime *MapRuntime) *MapRunStatusTool {
|
||||||
|
return &MapRunStatusTool{runtime: runtime}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *MapRunStatusTool) Name() string {
|
||||||
|
return "map_run_status"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *MapRunStatusTool) Description() string {
|
||||||
|
return "Return current status/progress for an llm_map or agentic_map run."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *MapRunStatusTool) Parameters() map[string]interface{} {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"run_id": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Map run ID returned by llm_map or agentic_map.",
|
||||||
|
},
|
||||||
|
"process_steps": map[string]interface{}{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Optional number of worker steps to execute before reading status (default 8).",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"run_id"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *MapRunStatusTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
|
||||||
|
if t.runtime == nil {
|
||||||
|
return ErrorResult("map runtime is not configured").WithError(fmt.Errorf("map runtime is nil"))
|
||||||
|
}
|
||||||
|
runIDRaw, ok := args["run_id"].(string)
|
||||||
|
if !ok || strings.TrimSpace(runIDRaw) == "" {
|
||||||
|
return ErrorResult("run_id is required").WithError(fmt.Errorf("run_id is required"))
|
||||||
|
}
|
||||||
|
runID, err := ids.Parse(strings.TrimSpace(runIDRaw))
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult("run_id must be a valid UUID").WithError(err)
|
||||||
|
}
|
||||||
|
steps, err := parseMapLimit(args, "process_steps", 8, 500)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult(err.Error()).WithError(err)
|
||||||
|
}
|
||||||
|
if steps > 0 {
|
||||||
|
if _, err := t.runtime.ProcessPending(ctx, steps); err != nil {
|
||||||
|
return ErrorResult(fmt.Sprintf("failed to process map jobs: %v", err)).WithError(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run, err := t.runtime.GetRun(ctx, runID)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult(fmt.Sprintf("failed to load map run: %v", err)).WithError(err)
|
||||||
|
}
|
||||||
|
payload := toMapRunProjection(run)
|
||||||
|
data, err := jsonv2.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult("failed to serialize map run status").WithError(err)
|
||||||
|
}
|
||||||
|
return &ToolResult{
|
||||||
|
ForLLM: string(data),
|
||||||
|
ForUser: string(data),
|
||||||
|
Silent: false,
|
||||||
|
IsError: false,
|
||||||
|
Async: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type MapRunReadTool struct {
|
||||||
|
runtime *MapRuntime
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMapRunReadTool(runtime *MapRuntime) *MapRunReadTool {
|
||||||
|
return &MapRunReadTool{runtime: runtime}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *MapRunReadTool) Name() string {
|
||||||
|
return "map_run_read"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *MapRunReadTool) Description() string {
|
||||||
|
return "Read paged item results for a map run; output as JSON or generated JSONL."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *MapRunReadTool) Parameters() map[string]interface{} {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"run_id": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Map run ID returned by llm_map or agentic_map.",
|
||||||
|
},
|
||||||
|
"offset": map[string]interface{}{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Pagination offset (default 0).",
|
||||||
|
},
|
||||||
|
"limit": map[string]interface{}{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Pagination limit (default 100, max 500).",
|
||||||
|
},
|
||||||
|
"format": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Projection format: json (default) or jsonl.",
|
||||||
|
},
|
||||||
|
"process_steps": map[string]interface{}{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Optional worker steps to process before reading (default 8).",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"run_id"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *MapRunReadTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
|
||||||
|
if t.runtime == nil {
|
||||||
|
return ErrorResult("map runtime is not configured").WithError(fmt.Errorf("map runtime is nil"))
|
||||||
|
}
|
||||||
|
runIDRaw, ok := args["run_id"].(string)
|
||||||
|
if !ok || strings.TrimSpace(runIDRaw) == "" {
|
||||||
|
return ErrorResult("run_id is required").WithError(fmt.Errorf("run_id is required"))
|
||||||
|
}
|
||||||
|
runID, err := ids.Parse(strings.TrimSpace(runIDRaw))
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult("run_id must be a valid UUID").WithError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
offset, err := parseMapLimit(args, "offset", 0, 1_000_000)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult(err.Error()).WithError(err)
|
||||||
|
}
|
||||||
|
limit, err := parseMapLimit(args, "limit", defaultMapReadLimit, maxMapReadLimit)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult(err.Error()).WithError(err)
|
||||||
|
}
|
||||||
|
format, err := parseMapReadFormat(args)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult(err.Error()).WithError(err)
|
||||||
|
}
|
||||||
|
steps, err := parseMapLimit(args, "process_steps", 8, 500)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult(err.Error()).WithError(err)
|
||||||
|
}
|
||||||
|
if steps > 0 {
|
||||||
|
if _, err := t.runtime.ProcessPending(ctx, steps); err != nil {
|
||||||
|
return ErrorResult(fmt.Sprintf("failed to process map jobs: %v", err)).WithError(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run, err := t.runtime.GetRun(ctx, runID)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult(fmt.Sprintf("failed to load map run: %v", err)).WithError(err)
|
||||||
|
}
|
||||||
|
items, err := t.runtime.ReadRunItems(ctx, runID, offset, limit)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult(fmt.Sprintf("failed to load map run items: %v", err)).WithError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"run": toMapRunProjection(run),
|
||||||
|
"offset": offset,
|
||||||
|
"limit": limit,
|
||||||
|
"format": format,
|
||||||
|
}
|
||||||
|
if format == "jsonl" {
|
||||||
|
jsonlText, err := encodeMapItemsJSONL(items)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult("failed to encode JSONL projection").WithError(err)
|
||||||
|
}
|
||||||
|
payload["items_jsonl"] = jsonlText
|
||||||
|
payload["item_count"] = len(items)
|
||||||
|
} else {
|
||||||
|
payload["items"] = items
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := jsonv2.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult("failed to serialize map run page").WithError(err)
|
||||||
|
}
|
||||||
|
return &ToolResult{
|
||||||
|
ForLLM: string(data),
|
||||||
|
ForUser: string(data),
|
||||||
|
Silent: false,
|
||||||
|
IsError: false,
|
||||||
|
Async: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func encodeMapItemsJSONL(items []MapItemProjection) (string, error) {
|
||||||
|
if len(items) == 0 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
lines := make([]string, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
b, err := jsonv2.Marshal(item)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
lines = append(lines, string(b))
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n"), nil
|
||||||
|
}
|
||||||
833
pkg/tools/map_runtime.go
Normal file
833
pkg/tools/map_runtime.go
Normal file
|
|
@ -0,0 +1,833 @@
|
||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
fantasy "charm.land/fantasy"
|
||||||
|
"github.com/ZanzyTHEbar/dragonscale/pkg/ids"
|
||||||
|
"github.com/ZanzyTHEbar/dragonscale/pkg/logger"
|
||||||
|
memsqlc "github.com/ZanzyTHEbar/dragonscale/pkg/memory/sqlc"
|
||||||
|
"github.com/ZanzyTHEbar/dragonscale/pkg/worker"
|
||||||
|
jsonv2 "github.com/go-json-experiment/json"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
mapRunStatusQueued = "queued"
|
||||||
|
mapRunStatusRunning = "running"
|
||||||
|
mapRunStatusSucceeded = "succeeded"
|
||||||
|
mapRunStatusFailed = "failed"
|
||||||
|
mapRunStatusCancelled = "cancelled"
|
||||||
|
|
||||||
|
mapItemStatusQueued = "queued"
|
||||||
|
mapItemStatusRunning = "running"
|
||||||
|
mapItemStatusSucceeded = "succeeded"
|
||||||
|
mapItemStatusFailed = "failed"
|
||||||
|
|
||||||
|
mapJobKindLLMItem = "map_item_llm"
|
||||||
|
mapJobKindAgenticItem = "map_item_agentic"
|
||||||
|
)
|
||||||
|
|
||||||
|
type mapJobPayload struct {
|
||||||
|
RunID string `json:"run_id"`
|
||||||
|
ItemID string `json:"item_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MapRunProjection struct {
|
||||||
|
RunID string `json:"run_id"`
|
||||||
|
OperatorKind string `json:"operator_kind"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
TotalItems int64 `json:"total_items"`
|
||||||
|
QueuedItems int64 `json:"queued_items"`
|
||||||
|
RunningItems int64 `json:"running_items"`
|
||||||
|
SucceededItems int64 `json:"succeeded_items"`
|
||||||
|
FailedItems int64 `json:"failed_items"`
|
||||||
|
LastError string `json:"last_error,omitempty"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
CompletedAt string `json:"completed_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MapItemProjection struct {
|
||||||
|
ItemID string `json:"item_id"`
|
||||||
|
Index int64 `json:"index"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Attempts int64 `json:"attempts"`
|
||||||
|
Input interface{} `json:"input,omitempty"`
|
||||||
|
Output interface{} `json:"output,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
InputHash string `json:"input_hash,omitempty"`
|
||||||
|
OutputHash string `json:"output_hash,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MapRuntime struct {
|
||||||
|
queries *memsqlc.Queries
|
||||||
|
agentID string
|
||||||
|
llmModel fantasy.LanguageModel
|
||||||
|
llmModelID string
|
||||||
|
subagentManager *SubagentManager
|
||||||
|
workerLockedBy string
|
||||||
|
processMu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMapRuntime(
|
||||||
|
queries *memsqlc.Queries,
|
||||||
|
agentID string,
|
||||||
|
llmModel fantasy.LanguageModel,
|
||||||
|
llmModelID string,
|
||||||
|
subagentManager *SubagentManager,
|
||||||
|
) *MapRuntime {
|
||||||
|
if queries == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &MapRuntime{
|
||||||
|
queries: queries,
|
||||||
|
agentID: agentID,
|
||||||
|
llmModel: llmModel,
|
||||||
|
llmModelID: llmModelID,
|
||||||
|
subagentManager: subagentManager,
|
||||||
|
workerLockedBy: "map-runtime",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rt *MapRuntime) EnqueueRun(
|
||||||
|
ctx context.Context,
|
||||||
|
sessionKey string,
|
||||||
|
spec MapRunSpec,
|
||||||
|
items []mapBoundaryItem,
|
||||||
|
idempotencyKey string,
|
||||||
|
) (memsqlc.MapRun, bool, error) {
|
||||||
|
if rt == nil || rt.queries == nil {
|
||||||
|
return memsqlc.MapRun{}, false, fmt.Errorf("map runtime is not configured")
|
||||||
|
}
|
||||||
|
if len(items) == 0 {
|
||||||
|
return memsqlc.MapRun{}, false, fmt.Errorf("at least one item is required")
|
||||||
|
}
|
||||||
|
if sessionKey == "" {
|
||||||
|
sessionKey = "default"
|
||||||
|
}
|
||||||
|
if spec.OperatorKind != MapOperatorLLM && spec.OperatorKind != MapOperatorAgentic {
|
||||||
|
return memsqlc.MapRun{}, false, fmt.Errorf("unsupported operator kind %q", spec.OperatorKind)
|
||||||
|
}
|
||||||
|
seenIndices := make(map[int]struct{}, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
if item.Index < 0 || uint64(item.Index) > uint64(^uint32(0)) {
|
||||||
|
return memsqlc.MapRun{}, false, fmt.Errorf("item index %d out of uint32 range", item.Index)
|
||||||
|
}
|
||||||
|
if _, exists := seenIndices[item.Index]; exists {
|
||||||
|
return memsqlc.MapRun{}, false, fmt.Errorf("duplicate item index %d", item.Index)
|
||||||
|
}
|
||||||
|
seenIndices[item.Index] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
specFB, err := EncodeMapRunSpecFlatBuffer(spec)
|
||||||
|
if err != nil {
|
||||||
|
return memsqlc.MapRun{}, false, fmt.Errorf("encode run spec: %w", err)
|
||||||
|
}
|
||||||
|
idemPtr := optionalStringPtr(strings.TrimSpace(idempotencyKey))
|
||||||
|
if idemPtr != nil {
|
||||||
|
existing, err := rt.queries.GetMapRunByIdempotencyKey(ctx, memsqlc.GetMapRunByIdempotencyKeyParams{
|
||||||
|
AgentID: rt.agentID,
|
||||||
|
SessionKey: sessionKey,
|
||||||
|
OperatorKind: string(spec.OperatorKind),
|
||||||
|
IdempotencyKey: idemPtr,
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
return existing, true, nil
|
||||||
|
}
|
||||||
|
if !errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return memsqlc.MapRun{}, false, fmt.Errorf("lookup idempotent run: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
runID := ids.New()
|
||||||
|
run, err := rt.queries.InsertMapRun(ctx, memsqlc.InsertMapRunParams{
|
||||||
|
ID: runID,
|
||||||
|
AgentID: rt.agentID,
|
||||||
|
SessionKey: sessionKey,
|
||||||
|
OperatorKind: string(spec.OperatorKind),
|
||||||
|
IdempotencyKey: idemPtr,
|
||||||
|
Status: mapRunStatusQueued,
|
||||||
|
TotalItems: int64(len(items)),
|
||||||
|
QueuedItems: int64(len(items)),
|
||||||
|
RunningItems: 0,
|
||||||
|
SucceededItems: 0,
|
||||||
|
FailedItems: 0,
|
||||||
|
SpecFb: specFB,
|
||||||
|
LastError: nil,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if idemPtr != nil && isUniqueConstraintError(err) {
|
||||||
|
existing, lookupErr := rt.queries.GetMapRunByIdempotencyKey(ctx, memsqlc.GetMapRunByIdempotencyKeyParams{
|
||||||
|
AgentID: rt.agentID,
|
||||||
|
SessionKey: sessionKey,
|
||||||
|
OperatorKind: string(spec.OperatorKind),
|
||||||
|
IdempotencyKey: idemPtr,
|
||||||
|
})
|
||||||
|
if lookupErr == nil {
|
||||||
|
return existing, true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return memsqlc.MapRun{}, false, fmt.Errorf("insert map run: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
jobKind := mapJobKindLLMItem
|
||||||
|
if spec.OperatorKind == MapOperatorAgentic {
|
||||||
|
jobKind = mapJobKindAgenticItem
|
||||||
|
}
|
||||||
|
maxAttempts := int64(spec.MaxRetries) + 1
|
||||||
|
if maxAttempts < 1 {
|
||||||
|
maxAttempts = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, item := range items {
|
||||||
|
inputFB, encErr := EncodeMapItemInputFlatBuffer(MapItemInputRecord{
|
||||||
|
ItemIndex: uint32(item.Index),
|
||||||
|
ItemJSON: item.ItemJSON,
|
||||||
|
InputHash: item.InputHash,
|
||||||
|
})
|
||||||
|
if encErr != nil {
|
||||||
|
msg := fmt.Sprintf("encode item %d: %v", item.Index, encErr)
|
||||||
|
_, _ = rt.failRun(ctx, run.ID, &msg)
|
||||||
|
return memsqlc.MapRun{}, false, errors.New(msg)
|
||||||
|
}
|
||||||
|
itemID := ids.New()
|
||||||
|
if _, err := rt.queries.InsertMapItem(ctx, memsqlc.InsertMapItemParams{
|
||||||
|
ID: itemID,
|
||||||
|
RunID: run.ID,
|
||||||
|
ItemIndex: int64(item.Index),
|
||||||
|
Status: mapItemStatusQueued,
|
||||||
|
Attempts: 0,
|
||||||
|
LastError: nil,
|
||||||
|
InputFb: inputFB,
|
||||||
|
OutputFb: nil,
|
||||||
|
InputHash: optionalStringPtr(item.InputHash),
|
||||||
|
OutputHash: nil,
|
||||||
|
}); err != nil {
|
||||||
|
msg := fmt.Sprintf("insert map item %d: %v", item.Index, err)
|
||||||
|
_, _ = rt.failRun(ctx, run.ID, &msg)
|
||||||
|
return memsqlc.MapRun{}, false, errors.New(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
dedupeKey := fmt.Sprintf("map:%s:%d", run.ID.String(), item.Index)
|
||||||
|
if _, err := rt.queries.EnqueueJob(ctx, memsqlc.EnqueueJobParams{
|
||||||
|
ID: ids.New(),
|
||||||
|
Kind: jobKind,
|
||||||
|
DedupeKey: &dedupeKey,
|
||||||
|
MaxAttempts: maxAttempts,
|
||||||
|
RunAt: time.Now().UTC(),
|
||||||
|
PayloadJson: []byte(`{}`),
|
||||||
|
}); err != nil {
|
||||||
|
msg := fmt.Sprintf("enqueue map item %d: %v", item.Index, err)
|
||||||
|
_, _ = rt.failRun(ctx, run.ID, &msg)
|
||||||
|
return memsqlc.MapRun{}, false, errors.New(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.InfoCF("map_runtime", "map run enqueued", map[string]interface{}{
|
||||||
|
"run_id": run.ID.String(),
|
||||||
|
"operator_kind": run.OperatorKind,
|
||||||
|
"items": len(items),
|
||||||
|
"idempotency": idempotencyKey,
|
||||||
|
})
|
||||||
|
return run, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rt *MapRuntime) ProcessPending(ctx context.Context, maxSteps int) (int, error) {
|
||||||
|
if rt == nil || rt.queries == nil {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
if maxSteps <= 0 {
|
||||||
|
maxSteps = 1
|
||||||
|
}
|
||||||
|
opts := &worker.Options{
|
||||||
|
LockedBy: rt.workerLockedBy,
|
||||||
|
Handlers: map[string]worker.HandlerFunc{
|
||||||
|
mapJobKindLLMItem: rt.handleLLMMapJob,
|
||||||
|
mapJobKindAgenticItem: rt.handleAgenticMapJob,
|
||||||
|
},
|
||||||
|
Backoff: func(_ int) time.Duration {
|
||||||
|
return 10 * time.Millisecond
|
||||||
|
},
|
||||||
|
Now: func() time.Time {
|
||||||
|
return time.Now().UTC()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
rt.processMu.Lock()
|
||||||
|
defer rt.processMu.Unlock()
|
||||||
|
|
||||||
|
processed := 0
|
||||||
|
for processed < maxSteps {
|
||||||
|
if _, err := rt.queries.FindNextRunnableJob(ctx); err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return processed, err
|
||||||
|
}
|
||||||
|
if err := worker.RunOnce(ctx, rt.queries, opts); err != nil {
|
||||||
|
return processed, err
|
||||||
|
}
|
||||||
|
processed++
|
||||||
|
}
|
||||||
|
return processed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rt *MapRuntime) ProcessRunToTerminal(ctx context.Context, runID ids.UUID, maxSteps int) (memsqlc.MapRun, error) {
|
||||||
|
if maxSteps <= 0 {
|
||||||
|
maxSteps = 2000
|
||||||
|
}
|
||||||
|
for i := 0; i < maxSteps; i++ {
|
||||||
|
run, err := rt.queries.GetMapRunByID(ctx, memsqlc.GetMapRunByIDParams{ID: runID})
|
||||||
|
if err != nil {
|
||||||
|
return memsqlc.MapRun{}, err
|
||||||
|
}
|
||||||
|
if isTerminalMapRunStatus(run.Status) {
|
||||||
|
return run, nil
|
||||||
|
}
|
||||||
|
processed, err := rt.ProcessPending(ctx, 1)
|
||||||
|
if err != nil {
|
||||||
|
return memsqlc.MapRun{}, err
|
||||||
|
}
|
||||||
|
if processed == 0 {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return memsqlc.MapRun{}, ctx.Err()
|
||||||
|
case <-time.After(10 * time.Millisecond):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return memsqlc.MapRun{}, fmt.Errorf("map run %s did not reach terminal status within %d steps", runID.String(), maxSteps)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rt *MapRuntime) GetRun(ctx context.Context, runID ids.UUID) (memsqlc.MapRun, error) {
|
||||||
|
if rt == nil || rt.queries == nil {
|
||||||
|
return memsqlc.MapRun{}, fmt.Errorf("map runtime is not configured")
|
||||||
|
}
|
||||||
|
return rt.queries.GetMapRunByID(ctx, memsqlc.GetMapRunByIDParams{ID: runID})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rt *MapRuntime) ReadRunItems(ctx context.Context, runID ids.UUID, offset, limit int) ([]MapItemProjection, error) {
|
||||||
|
if rt == nil || rt.queries == nil {
|
||||||
|
return nil, fmt.Errorf("map runtime is not configured")
|
||||||
|
}
|
||||||
|
rows, err := rt.queries.ListMapItemsByRunPaged(ctx, memsqlc.ListMapItemsByRunPagedParams{
|
||||||
|
RunID: runID,
|
||||||
|
Lim: int64(limit),
|
||||||
|
Off: int64(offset),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items := make([]MapItemProjection, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
inputRec, inErr := DecodeMapItemInputFlatBuffer(row.InputFb)
|
||||||
|
if inErr != nil {
|
||||||
|
return nil, fmt.Errorf("decode item input %s: %w", row.ID.String(), inErr)
|
||||||
|
}
|
||||||
|
var inputAny interface{}
|
||||||
|
if err := jsonv2.Unmarshal([]byte(inputRec.ItemJSON), &inputAny); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode item JSON %s: %w", row.ID.String(), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
proj := MapItemProjection{
|
||||||
|
ItemID: row.ID.String(),
|
||||||
|
Index: row.ItemIndex,
|
||||||
|
Status: row.Status,
|
||||||
|
Attempts: row.Attempts,
|
||||||
|
Input: inputAny,
|
||||||
|
}
|
||||||
|
if row.InputHash != nil {
|
||||||
|
proj.InputHash = *row.InputHash
|
||||||
|
}
|
||||||
|
if row.LastError != nil {
|
||||||
|
proj.Error = *row.LastError
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(row.OutputFb) > 0 {
|
||||||
|
outputRec, outErr := DecodeMapItemOutputFlatBuffer(row.OutputFb)
|
||||||
|
if outErr != nil {
|
||||||
|
return nil, fmt.Errorf("decode item output %s: %w", row.ID.String(), outErr)
|
||||||
|
}
|
||||||
|
if row.OutputHash != nil && outputRec.OutputHash != "" && *row.OutputHash != outputRec.OutputHash {
|
||||||
|
return nil, fmt.Errorf("output hash mismatch for item %s", row.ID.String())
|
||||||
|
}
|
||||||
|
if outputRec.ErrorText != "" && proj.Error == "" {
|
||||||
|
proj.Error = outputRec.ErrorText
|
||||||
|
}
|
||||||
|
if outputRec.OutputHash != "" {
|
||||||
|
proj.OutputHash = outputRec.OutputHash
|
||||||
|
} else if row.OutputHash != nil {
|
||||||
|
proj.OutputHash = *row.OutputHash
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(outputRec.OutputJSON) != "" {
|
||||||
|
var outputAny interface{}
|
||||||
|
if err := jsonv2.Unmarshal([]byte(outputRec.OutputJSON), &outputAny); err == nil {
|
||||||
|
proj.Output = outputAny
|
||||||
|
} else {
|
||||||
|
// Keep malformed model output visible to callers for debugging.
|
||||||
|
proj.Output = outputRec.OutputJSON
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items = append(items, proj)
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rt *MapRuntime) handleLLMMapJob(ctx context.Context, q *memsqlc.Queries, job memsqlc.Job) error {
|
||||||
|
if rt.llmModel == nil {
|
||||||
|
return fmt.Errorf("llm_map runtime model is not configured")
|
||||||
|
}
|
||||||
|
item, run, spec, inputRec, err := rt.loadMapJobState(ctx, q, job)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if spec.OperatorKind != MapOperatorLLM {
|
||||||
|
errMsg := fmt.Sprintf("run %s operator kind mismatch: expected llm_map got %s", run.ID.String(), spec.OperatorKind)
|
||||||
|
_, _ = q.MarkMapItemFailed(ctx, memsqlc.MarkMapItemFailedParams{LastError: &errMsg, ID: item.ID})
|
||||||
|
if _, refreshErr := rt.refreshRunProgress(ctx, q, run.ID, &errMsg); refreshErr != nil {
|
||||||
|
logger.WarnCF("map_runtime", "failed to refresh run progress", map[string]interface{}{
|
||||||
|
"run_id": run.ID.String(),
|
||||||
|
"error": refreshErr.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
outJSON, callErr := rt.executeLLMMapItem(ctx, spec, inputRec.ItemJSON)
|
||||||
|
if callErr != nil {
|
||||||
|
if job.Attempts >= job.MaxAttempts {
|
||||||
|
msg := callErr.Error()
|
||||||
|
_, _ = q.MarkMapItemFailed(ctx, memsqlc.MarkMapItemFailedParams{LastError: &msg, ID: item.ID})
|
||||||
|
if _, refreshErr := rt.refreshRunProgress(ctx, q, run.ID, &msg); refreshErr != nil {
|
||||||
|
logger.WarnCF("map_runtime", "failed to refresh run progress", map[string]interface{}{
|
||||||
|
"run_id": run.ID.String(),
|
||||||
|
"error": refreshErr.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return callErr
|
||||||
|
}
|
||||||
|
|
||||||
|
outHash := hashString(outJSON)
|
||||||
|
outputFB, err := EncodeMapItemOutputFlatBuffer(MapItemOutputRecord{
|
||||||
|
ItemIndex: uint32(item.ItemIndex),
|
||||||
|
Success: true,
|
||||||
|
Attempts: uint16(item.Attempts),
|
||||||
|
OutputJSON: outJSON,
|
||||||
|
OutputHash: outHash,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
msg := fmt.Sprintf("encode output for item %s: %v", item.ID.String(), err)
|
||||||
|
_, _ = q.MarkMapItemFailed(ctx, memsqlc.MarkMapItemFailedParams{LastError: &msg, ID: item.ID})
|
||||||
|
if _, refreshErr := rt.refreshRunProgress(ctx, q, run.ID, &msg); refreshErr != nil {
|
||||||
|
logger.WarnCF("map_runtime", "failed to refresh run progress", map[string]interface{}{
|
||||||
|
"run_id": run.ID.String(),
|
||||||
|
"error": refreshErr.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if _, err := q.MarkMapItemSucceeded(ctx, memsqlc.MarkMapItemSucceededParams{
|
||||||
|
OutputFb: outputFB,
|
||||||
|
OutputHash: &outHash,
|
||||||
|
ID: item.ID,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, refreshErr := rt.refreshRunProgress(ctx, q, run.ID, nil); refreshErr != nil {
|
||||||
|
logger.WarnCF("map_runtime", "failed to refresh run progress", map[string]interface{}{
|
||||||
|
"run_id": run.ID.String(),
|
||||||
|
"error": refreshErr.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rt *MapRuntime) handleAgenticMapJob(ctx context.Context, q *memsqlc.Queries, job memsqlc.Job) error {
|
||||||
|
if rt.subagentManager == nil {
|
||||||
|
return fmt.Errorf("agentic_map runtime manager is not configured")
|
||||||
|
}
|
||||||
|
item, run, spec, inputRec, err := rt.loadMapJobState(ctx, q, job)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if spec.OperatorKind != MapOperatorAgentic {
|
||||||
|
errMsg := fmt.Sprintf("run %s operator kind mismatch: expected agentic_map got %s", run.ID.String(), spec.OperatorKind)
|
||||||
|
_, _ = q.MarkMapItemFailed(ctx, memsqlc.MarkMapItemFailedParams{LastError: &errMsg, ID: item.ID})
|
||||||
|
if _, refreshErr := rt.refreshRunProgress(ctx, q, run.ID, &errMsg); refreshErr != nil {
|
||||||
|
logger.WarnCF("map_runtime", "failed to refresh run progress", map[string]interface{}{
|
||||||
|
"run_id": run.ID.String(),
|
||||||
|
"error": refreshErr.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
outJSON, callErr := rt.executeAgenticMapItem(ctx, spec, int(item.ItemIndex), inputRec.ItemJSON)
|
||||||
|
if callErr != nil {
|
||||||
|
if job.Attempts >= job.MaxAttempts {
|
||||||
|
msg := callErr.Error()
|
||||||
|
_, _ = q.MarkMapItemFailed(ctx, memsqlc.MarkMapItemFailedParams{LastError: &msg, ID: item.ID})
|
||||||
|
if _, refreshErr := rt.refreshRunProgress(ctx, q, run.ID, &msg); refreshErr != nil {
|
||||||
|
logger.WarnCF("map_runtime", "failed to refresh run progress", map[string]interface{}{
|
||||||
|
"run_id": run.ID.String(),
|
||||||
|
"error": refreshErr.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return callErr
|
||||||
|
}
|
||||||
|
|
||||||
|
outHash := hashString(outJSON)
|
||||||
|
outputFB, err := EncodeMapItemOutputFlatBuffer(MapItemOutputRecord{
|
||||||
|
ItemIndex: uint32(item.ItemIndex),
|
||||||
|
Success: true,
|
||||||
|
Attempts: uint16(item.Attempts),
|
||||||
|
OutputJSON: outJSON,
|
||||||
|
OutputHash: outHash,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
msg := fmt.Sprintf("encode output for item %s: %v", item.ID.String(), err)
|
||||||
|
_, _ = q.MarkMapItemFailed(ctx, memsqlc.MarkMapItemFailedParams{LastError: &msg, ID: item.ID})
|
||||||
|
if _, refreshErr := rt.refreshRunProgress(ctx, q, run.ID, &msg); refreshErr != nil {
|
||||||
|
logger.WarnCF("map_runtime", "failed to refresh run progress", map[string]interface{}{
|
||||||
|
"run_id": run.ID.String(),
|
||||||
|
"error": refreshErr.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if _, err := q.MarkMapItemSucceeded(ctx, memsqlc.MarkMapItemSucceededParams{
|
||||||
|
OutputFb: outputFB,
|
||||||
|
OutputHash: &outHash,
|
||||||
|
ID: item.ID,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, refreshErr := rt.refreshRunProgress(ctx, q, run.ID, nil); refreshErr != nil {
|
||||||
|
logger.WarnCF("map_runtime", "failed to refresh run progress", map[string]interface{}{
|
||||||
|
"run_id": run.ID.String(),
|
||||||
|
"error": refreshErr.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rt *MapRuntime) loadMapJobState(
|
||||||
|
ctx context.Context,
|
||||||
|
q *memsqlc.Queries,
|
||||||
|
job memsqlc.Job,
|
||||||
|
) (memsqlc.MapItem, memsqlc.MapRun, MapRunSpec, MapItemInputRecord, error) {
|
||||||
|
itemID, dedupeErr := mapJobItemIDFromDedupeKey(ctx, q, job.DedupeKey)
|
||||||
|
if dedupeErr != nil {
|
||||||
|
payload, payloadErr := parseMapJobPayload(job.PayloadJson)
|
||||||
|
if payloadErr != nil {
|
||||||
|
return memsqlc.MapItem{}, memsqlc.MapRun{}, MapRunSpec{}, MapItemInputRecord{}, fmt.Errorf(
|
||||||
|
"invalid map job identity: dedupe_key_error=%v payload_error=%v",
|
||||||
|
dedupeErr,
|
||||||
|
payloadErr,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
parsedID, parseErr := ids.Parse(payload.ItemID)
|
||||||
|
if parseErr != nil {
|
||||||
|
return memsqlc.MapItem{}, memsqlc.MapRun{}, MapRunSpec{}, MapItemInputRecord{}, fmt.Errorf("invalid item id in job payload: %w", parseErr)
|
||||||
|
}
|
||||||
|
itemID = parsedID
|
||||||
|
}
|
||||||
|
item, err := q.MarkMapItemRunning(ctx, memsqlc.MarkMapItemRunningParams{ID: itemID})
|
||||||
|
if err != nil {
|
||||||
|
return memsqlc.MapItem{}, memsqlc.MapRun{}, MapRunSpec{}, MapItemInputRecord{}, err
|
||||||
|
}
|
||||||
|
run, err := q.GetMapRunByID(ctx, memsqlc.GetMapRunByIDParams{ID: item.RunID})
|
||||||
|
if err != nil {
|
||||||
|
return memsqlc.MapItem{}, memsqlc.MapRun{}, MapRunSpec{}, MapItemInputRecord{}, err
|
||||||
|
}
|
||||||
|
spec, err := DecodeMapRunSpecFlatBuffer(run.SpecFb)
|
||||||
|
if err != nil {
|
||||||
|
return memsqlc.MapItem{}, memsqlc.MapRun{}, MapRunSpec{}, MapItemInputRecord{}, fmt.Errorf("decode run spec: %w", err)
|
||||||
|
}
|
||||||
|
inputRec, err := DecodeMapItemInputFlatBuffer(item.InputFb)
|
||||||
|
if err != nil {
|
||||||
|
return memsqlc.MapItem{}, memsqlc.MapRun{}, MapRunSpec{}, MapItemInputRecord{}, fmt.Errorf("decode item input: %w", err)
|
||||||
|
}
|
||||||
|
return item, run, spec, inputRec, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rt *MapRuntime) executeLLMMapItem(ctx context.Context, spec MapRunSpec, itemJSON string) (string, error) {
|
||||||
|
maxOut := int64(512)
|
||||||
|
prompt := fmt.Sprintf(
|
||||||
|
"Instruction:\n%s\n\nItem JSON:\n%s\n\nReturn exactly one JSON object and nothing else.",
|
||||||
|
spec.Instruction,
|
||||||
|
itemJSON,
|
||||||
|
)
|
||||||
|
call := fantasy.Call{
|
||||||
|
Prompt: fantasy.Prompt{
|
||||||
|
fantasy.NewSystemMessage("You are a pure mapper. Return only strict JSON object output."),
|
||||||
|
fantasy.NewUserMessage(prompt),
|
||||||
|
},
|
||||||
|
MaxOutputTokens: &maxOut,
|
||||||
|
}
|
||||||
|
resp, err := rt.llmModel.Generate(ctx, call)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
outText := strings.TrimSpace(resp.Content.Text())
|
||||||
|
var out map[string]interface{}
|
||||||
|
if err := jsonv2.Unmarshal([]byte(outText), &out); err != nil {
|
||||||
|
return "", fmt.Errorf("llm_map output is not valid JSON object: %w", err)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(spec.OutputSchemaJSON) != "" {
|
||||||
|
var schema map[string]interface{}
|
||||||
|
if err := jsonv2.Unmarshal([]byte(spec.OutputSchemaJSON), &schema); err != nil {
|
||||||
|
return "", fmt.Errorf("invalid output schema in run spec: %w", err)
|
||||||
|
}
|
||||||
|
if err := validateLLMMapOutputSchema(out, schema); err != nil {
|
||||||
|
return "", fmt.Errorf("schema validation failed: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
outJSON, err := canonicalJSONString(out)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("serialize model output: %w", err)
|
||||||
|
}
|
||||||
|
return outJSON, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rt *MapRuntime) executeAgenticMapItem(ctx context.Context, spec MapRunSpec, index int, itemJSON string) (string, error) {
|
||||||
|
if strings.TrimSpace(spec.TaskTemplate) == "" {
|
||||||
|
return "", fmt.Errorf("task_template is required for agentic_map")
|
||||||
|
}
|
||||||
|
if !strings.Contains(spec.TaskTemplate, "{{item_json}}") || !strings.Contains(spec.TaskTemplate, "{{index}}") {
|
||||||
|
return "", fmt.Errorf("task_template must include both {{item_json}} and {{index}} placeholders")
|
||||||
|
}
|
||||||
|
task := strings.ReplaceAll(spec.TaskTemplate, "{{item_json}}", itemJSON)
|
||||||
|
task = strings.ReplaceAll(task, "{{index}}", fmt.Sprintf("%d", index))
|
||||||
|
|
||||||
|
subTool := NewSubagentTool(rt.subagentManager)
|
||||||
|
originChannel := spec.OriginChannel
|
||||||
|
if originChannel == "" {
|
||||||
|
originChannel = "cli"
|
||||||
|
}
|
||||||
|
originChatID := spec.OriginChatID
|
||||||
|
if originChatID == "" {
|
||||||
|
originChatID = "direct"
|
||||||
|
}
|
||||||
|
subTool.SetContext(originChannel, originChatID)
|
||||||
|
|
||||||
|
callArgs := map[string]interface{}{
|
||||||
|
"task": task,
|
||||||
|
"label": fmt.Sprintf("agentic-map-%d", index),
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(spec.DelegatedScope) != "" {
|
||||||
|
callArgs["delegated_scope"] = spec.DelegatedScope
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(spec.KeptWork) != "" {
|
||||||
|
callArgs["kept_work"] = spec.KeptWork
|
||||||
|
}
|
||||||
|
|
||||||
|
result := subTool.Execute(ctx, callArgs)
|
||||||
|
if result == nil {
|
||||||
|
return "", fmt.Errorf("subagent returned nil result")
|
||||||
|
}
|
||||||
|
if result.IsError {
|
||||||
|
if result.Err != nil {
|
||||||
|
return "", result.Err
|
||||||
|
}
|
||||||
|
return "", errors.New(result.ForLLM)
|
||||||
|
}
|
||||||
|
|
||||||
|
outputPayload := map[string]interface{}{
|
||||||
|
"for_user": result.ForUser,
|
||||||
|
"for_llm": result.ForLLM,
|
||||||
|
}
|
||||||
|
outJSON, err := canonicalJSONString(outputPayload)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("serialize agentic output: %w", err)
|
||||||
|
}
|
||||||
|
return outJSON, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rt *MapRuntime) refreshRunProgress(
|
||||||
|
ctx context.Context,
|
||||||
|
q *memsqlc.Queries,
|
||||||
|
runID ids.UUID,
|
||||||
|
lastErr *string,
|
||||||
|
) (memsqlc.MapRun, error) {
|
||||||
|
total, err := q.CountMapItemsByRun(ctx, memsqlc.CountMapItemsByRunParams{RunID: runID})
|
||||||
|
if err != nil {
|
||||||
|
return memsqlc.MapRun{}, err
|
||||||
|
}
|
||||||
|
statusCounts, err := q.CountMapItemsByRunAndStatus(ctx, memsqlc.CountMapItemsByRunAndStatusParams{RunID: runID})
|
||||||
|
if err != nil {
|
||||||
|
return memsqlc.MapRun{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var queued, running, succeeded, failed int64
|
||||||
|
for _, row := range statusCounts {
|
||||||
|
switch row.Status {
|
||||||
|
case mapItemStatusQueued:
|
||||||
|
queued = row.Count
|
||||||
|
case mapItemStatusRunning:
|
||||||
|
running = row.Count
|
||||||
|
case mapItemStatusSucceeded:
|
||||||
|
succeeded = row.Count
|
||||||
|
case mapItemStatusFailed:
|
||||||
|
failed = row.Count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
status := mapRunStatusQueued
|
||||||
|
if running > 0 {
|
||||||
|
status = mapRunStatusRunning
|
||||||
|
}
|
||||||
|
if queued > 0 && running == 0 {
|
||||||
|
status = mapRunStatusQueued
|
||||||
|
}
|
||||||
|
if queued == 0 && running == 0 {
|
||||||
|
switch {
|
||||||
|
case failed > 0:
|
||||||
|
status = mapRunStatusFailed
|
||||||
|
case succeeded == total:
|
||||||
|
status = mapRunStatusSucceeded
|
||||||
|
default:
|
||||||
|
status = mapRunStatusFailed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var completedAt *time.Time
|
||||||
|
if isTerminalMapRunStatus(status) {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
completedAt = &now
|
||||||
|
}
|
||||||
|
return q.UpdateMapRunProgress(ctx, memsqlc.UpdateMapRunProgressParams{
|
||||||
|
Status: status,
|
||||||
|
QueuedItems: queued,
|
||||||
|
RunningItems: running,
|
||||||
|
SucceededItems: succeeded,
|
||||||
|
FailedItems: failed,
|
||||||
|
LastError: lastErr,
|
||||||
|
CompletedAt: completedAt,
|
||||||
|
ID: runID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rt *MapRuntime) failRun(ctx context.Context, runID ids.UUID, lastErr *string) (memsqlc.MapRun, error) {
|
||||||
|
return rt.queries.UpdateMapRunProgress(ctx, memsqlc.UpdateMapRunProgressParams{
|
||||||
|
Status: mapRunStatusFailed,
|
||||||
|
QueuedItems: 0,
|
||||||
|
RunningItems: 0,
|
||||||
|
SucceededItems: 0,
|
||||||
|
FailedItems: 0,
|
||||||
|
LastError: lastErr,
|
||||||
|
CompletedAt: ptrTime(time.Now().UTC()),
|
||||||
|
ID: runID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseMapJobPayload(raw []byte) (mapJobPayload, error) {
|
||||||
|
var payload mapJobPayload
|
||||||
|
if err := jsonv2.Unmarshal(raw, &payload); err != nil {
|
||||||
|
return mapJobPayload{}, err
|
||||||
|
}
|
||||||
|
if payload.RunID == "" || payload.ItemID == "" {
|
||||||
|
return mapJobPayload{}, fmt.Errorf("run_id and item_id are required")
|
||||||
|
}
|
||||||
|
return payload, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapJobItemIDFromDedupeKey(ctx context.Context, q *memsqlc.Queries, dedupeKey *string) (ids.UUID, error) {
|
||||||
|
runID, itemIndex, err := parseMapJobDedupeKey(dedupeKey)
|
||||||
|
if err != nil {
|
||||||
|
return ids.UUID{}, err
|
||||||
|
}
|
||||||
|
item, err := q.GetMapItemByRunAndIndex(ctx, memsqlc.GetMapItemByRunAndIndexParams{
|
||||||
|
RunID: runID,
|
||||||
|
ItemIndex: itemIndex,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return ids.UUID{}, fmt.Errorf("resolve map item by dedupe key: %w", err)
|
||||||
|
}
|
||||||
|
return item.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseMapJobDedupeKey(dedupeKey *string) (ids.UUID, int64, error) {
|
||||||
|
if dedupeKey == nil || strings.TrimSpace(*dedupeKey) == "" {
|
||||||
|
return ids.UUID{}, 0, fmt.Errorf("dedupe key is required")
|
||||||
|
}
|
||||||
|
parts := strings.Split(strings.TrimSpace(*dedupeKey), ":")
|
||||||
|
if len(parts) != 3 || parts[0] != "map" {
|
||||||
|
return ids.UUID{}, 0, fmt.Errorf("invalid dedupe key format: %q", strings.TrimSpace(*dedupeKey))
|
||||||
|
}
|
||||||
|
runID, err := ids.Parse(parts[1])
|
||||||
|
if err != nil {
|
||||||
|
return ids.UUID{}, 0, fmt.Errorf("invalid run id in dedupe key: %w", err)
|
||||||
|
}
|
||||||
|
itemIndex, err := strconv.ParseInt(parts[2], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return ids.UUID{}, 0, fmt.Errorf("invalid item index in dedupe key: %w", err)
|
||||||
|
}
|
||||||
|
if itemIndex < 0 {
|
||||||
|
return ids.UUID{}, 0, fmt.Errorf("item index in dedupe key must be non-negative")
|
||||||
|
}
|
||||||
|
return runID, itemIndex, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func toMapRunProjection(run memsqlc.MapRun) MapRunProjection {
|
||||||
|
p := MapRunProjection{
|
||||||
|
RunID: run.ID.String(),
|
||||||
|
OperatorKind: run.OperatorKind,
|
||||||
|
Status: run.Status,
|
||||||
|
TotalItems: run.TotalItems,
|
||||||
|
QueuedItems: run.QueuedItems,
|
||||||
|
RunningItems: run.RunningItems,
|
||||||
|
SucceededItems: run.SucceededItems,
|
||||||
|
FailedItems: run.FailedItems,
|
||||||
|
CreatedAt: run.CreatedAt.Format(time.RFC3339Nano),
|
||||||
|
UpdatedAt: run.UpdatedAt.Format(time.RFC3339Nano),
|
||||||
|
}
|
||||||
|
if run.LastError != nil {
|
||||||
|
p.LastError = *run.LastError
|
||||||
|
}
|
||||||
|
if run.CompletedAt != nil {
|
||||||
|
p.CompletedAt = run.CompletedAt.Format(time.RFC3339Nano)
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func isTerminalMapRunStatus(status string) bool {
|
||||||
|
switch status {
|
||||||
|
case mapRunStatusSucceeded, mapRunStatusFailed, mapRunStatusCancelled:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func optionalStringPtr(v string) *string {
|
||||||
|
if strings.TrimSpace(v) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := strings.TrimSpace(v)
|
||||||
|
return &out
|
||||||
|
}
|
||||||
|
|
||||||
|
func isUniqueConstraintError(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
lower := strings.ToLower(err.Error())
|
||||||
|
return strings.Contains(lower, "unique constraint") ||
|
||||||
|
strings.Contains(lower, "constraint failed") ||
|
||||||
|
strings.Contains(lower, "duplicate key")
|
||||||
|
}
|
||||||
|
|
||||||
|
func ptrTime(v time.Time) *time.Time {
|
||||||
|
return &v
|
||||||
|
}
|
||||||
383
pkg/tools/map_runtime_integration_test.go
Normal file
383
pkg/tools/map_runtime_integration_test.go
Normal file
|
|
@ -0,0 +1,383 @@
|
||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
fantasy "charm.land/fantasy"
|
||||||
|
jsonv2 "github.com/go-json-experiment/json"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/ZanzyTHEbar/dragonscale/pkg/bus"
|
||||||
|
"github.com/ZanzyTHEbar/dragonscale/pkg/ids"
|
||||||
|
"github.com/ZanzyTHEbar/dragonscale/pkg/memory/delegate"
|
||||||
|
memsqlc "github.com/ZanzyTHEbar/dragonscale/pkg/memory/sqlc"
|
||||||
|
)
|
||||||
|
|
||||||
|
type scriptedMapLLM struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
responses []scriptedMapLLMResponse
|
||||||
|
index int
|
||||||
|
}
|
||||||
|
|
||||||
|
type scriptedMapLLMResponse struct {
|
||||||
|
Text string
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *scriptedMapLLM) Generate(_ context.Context, _ fantasy.Call) (*fantasy.Response, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
if len(m.responses) == 0 {
|
||||||
|
return nil, fmt.Errorf("no scripted llm responses")
|
||||||
|
}
|
||||||
|
resp := m.responses[m.index%len(m.responses)]
|
||||||
|
m.index++
|
||||||
|
if resp.Err != nil {
|
||||||
|
return nil, resp.Err
|
||||||
|
}
|
||||||
|
return &fantasy.Response{
|
||||||
|
Content: fantasy.ResponseContent{fantasy.TextContent{Text: resp.Text}},
|
||||||
|
FinishReason: fantasy.FinishReasonStop,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *scriptedMapLLM) Stream(ctx context.Context, call fantasy.Call) (fantasy.StreamResponse, error) {
|
||||||
|
resp, err := m.Generate(ctx, call)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return func(yield func(fantasy.StreamPart) bool) {
|
||||||
|
if !yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeTextDelta, Delta: resp.Content.Text()}) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeFinish, FinishReason: fantasy.FinishReasonStop})
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *scriptedMapLLM) GenerateObject(_ context.Context, _ fantasy.ObjectCall) (*fantasy.ObjectResponse, error) {
|
||||||
|
return nil, fmt.Errorf("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *scriptedMapLLM) StreamObject(_ context.Context, _ fantasy.ObjectCall) (fantasy.ObjectStreamResponse, error) {
|
||||||
|
return nil, fmt.Errorf("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *scriptedMapLLM) Provider() string { return "mock" }
|
||||||
|
func (m *scriptedMapLLM) Model() string { return "mock-map-llm" }
|
||||||
|
|
||||||
|
func newMapRuntimeForTest(t *testing.T, llm fantasy.LanguageModel, manager *SubagentManager) *MapRuntime {
|
||||||
|
t.Helper()
|
||||||
|
d, err := delegate.NewLibSQLInMemory()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, d.Init(context.Background()))
|
||||||
|
t.Cleanup(func() { _ = d.Close() })
|
||||||
|
|
||||||
|
return NewMapRuntime(d.Queries(), "dragonscale", llm, "mock-map-llm", manager)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeResultMap(t *testing.T, result *ToolResult) map[string]interface{} {
|
||||||
|
t.Helper()
|
||||||
|
require.NotNil(t, result)
|
||||||
|
require.False(t, result.IsError, result.ForLLM)
|
||||||
|
var payload map[string]interface{}
|
||||||
|
require.NoError(t, jsonv2.Unmarshal([]byte(result.ForLLM), &payload))
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLLMMap_WorkerJSONL_StatusAndRead(t *testing.T) {
|
||||||
|
model := &scriptedMapLLM{
|
||||||
|
responses: []scriptedMapLLMResponse{
|
||||||
|
{Text: `{"label":"alpha","priority":1}`},
|
||||||
|
{Text: `{"label":"beta","priority":2}`},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
runtime := newMapRuntimeForTest(t, model, nil)
|
||||||
|
mapTool := NewLLMMapTool(model, "mock-map-llm")
|
||||||
|
mapTool.SetRuntime(runtime)
|
||||||
|
statusTool := NewMapRunStatusTool(runtime)
|
||||||
|
readTool := NewMapRunReadTool(runtime)
|
||||||
|
|
||||||
|
enqueue := decodeResultMap(t, mapTool.Execute(context.Background(), map[string]interface{}{
|
||||||
|
"instruction": "normalize to {label, priority}",
|
||||||
|
"input_jsonl": "{\"name\":\"a\"}\n{\"name\":\"b\"}",
|
||||||
|
"execution_mode": "worker",
|
||||||
|
}))
|
||||||
|
runID, _ := enqueue["run_id"].(string)
|
||||||
|
require.NotEmpty(t, runID)
|
||||||
|
assert.Equal(t, "worker", enqueue["execution_mode"])
|
||||||
|
|
||||||
|
status := decodeResultMap(t, statusTool.Execute(context.Background(), map[string]interface{}{
|
||||||
|
"run_id": runID,
|
||||||
|
"process_steps": float64(20),
|
||||||
|
}))
|
||||||
|
assert.Equal(t, mapRunStatusSucceeded, status["status"])
|
||||||
|
assert.EqualValues(t, 2, status["succeeded_items"])
|
||||||
|
|
||||||
|
readJSON := decodeResultMap(t, readTool.Execute(context.Background(), map[string]interface{}{
|
||||||
|
"run_id": runID,
|
||||||
|
"limit": float64(10),
|
||||||
|
"format": "json",
|
||||||
|
}))
|
||||||
|
itemsAny, ok := readJSON["items"].([]interface{})
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Len(t, itemsAny, 2)
|
||||||
|
|
||||||
|
readJSONL := decodeResultMap(t, readTool.Execute(context.Background(), map[string]interface{}{
|
||||||
|
"run_id": runID,
|
||||||
|
"format": "jsonl",
|
||||||
|
}))
|
||||||
|
jsonlText, _ := readJSONL["items_jsonl"].(string)
|
||||||
|
assert.Contains(t, jsonlText, `"status":"succeeded"`)
|
||||||
|
assert.Contains(t, jsonlText, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLLMMap_IdempotencyReuse(t *testing.T) {
|
||||||
|
model := &scriptedMapLLM{
|
||||||
|
responses: []scriptedMapLLMResponse{
|
||||||
|
{Text: `{"label":"alpha"}`},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
runtime := newMapRuntimeForTest(t, model, nil)
|
||||||
|
mapTool := NewLLMMapTool(model, "mock-map-llm")
|
||||||
|
mapTool.SetRuntime(runtime)
|
||||||
|
|
||||||
|
args := map[string]interface{}{
|
||||||
|
"instruction": "normalize",
|
||||||
|
"items": []interface{}{map[string]interface{}{"name": "a"}},
|
||||||
|
"execution_mode": "worker",
|
||||||
|
"idempotency_key": "run-1",
|
||||||
|
"session_key": "sess-a",
|
||||||
|
}
|
||||||
|
first := decodeResultMap(t, mapTool.Execute(context.Background(), args))
|
||||||
|
second := decodeResultMap(t, mapTool.Execute(context.Background(), args))
|
||||||
|
|
||||||
|
assert.Equal(t, first["run_id"], second["run_id"])
|
||||||
|
assert.Equal(t, true, second["idempotent_reuse"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLLMMap_IdempotencyReuse_Concurrent(t *testing.T) {
|
||||||
|
model := &scriptedMapLLM{
|
||||||
|
responses: []scriptedMapLLMResponse{
|
||||||
|
{Text: `{"label":"alpha"}`},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
runtime := newMapRuntimeForTest(t, model, nil)
|
||||||
|
mapTool := NewLLMMapTool(model, "mock-map-llm")
|
||||||
|
mapTool.SetRuntime(runtime)
|
||||||
|
|
||||||
|
const workers = 12
|
||||||
|
start := make(chan struct{})
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
runIDs := make(chan string, workers)
|
||||||
|
errs := make(chan error, workers)
|
||||||
|
|
||||||
|
for i := 0; i < workers; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
<-start
|
||||||
|
result := mapTool.Execute(context.Background(), map[string]interface{}{
|
||||||
|
"instruction": "normalize",
|
||||||
|
"items": []interface{}{map[string]interface{}{"name": "a"}},
|
||||||
|
"execution_mode": "worker",
|
||||||
|
"idempotency_key": "run-1",
|
||||||
|
"session_key": "sess-a",
|
||||||
|
})
|
||||||
|
if result == nil || result.IsError {
|
||||||
|
if result != nil && result.Err != nil {
|
||||||
|
errs <- result.Err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
errs <- fmt.Errorf("unexpected map tool error: %v", result)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var payload map[string]interface{}
|
||||||
|
if err := jsonv2.Unmarshal([]byte(result.ForLLM), &payload); err != nil {
|
||||||
|
errs <- fmt.Errorf("decode result: %w", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
runID, _ := payload["run_id"].(string)
|
||||||
|
if runID == "" {
|
||||||
|
errs <- fmt.Errorf("missing run_id in payload")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
runIDs <- runID
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
close(start)
|
||||||
|
wg.Wait()
|
||||||
|
close(errs)
|
||||||
|
close(runIDs)
|
||||||
|
|
||||||
|
for err := range errs {
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
unique := make(map[string]struct{})
|
||||||
|
for runID := range runIDs {
|
||||||
|
unique[runID] = struct{}{}
|
||||||
|
}
|
||||||
|
require.Len(t, unique, 1, "all concurrent requests should reuse the same run id")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLLMMap_InlineRetriesThenSucceeds(t *testing.T) {
|
||||||
|
model := &scriptedMapLLM{
|
||||||
|
responses: []scriptedMapLLMResponse{
|
||||||
|
{Err: fmt.Errorf("transient model outage")},
|
||||||
|
{Text: `{"label":"ok"}`},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
runtime := newMapRuntimeForTest(t, model, nil)
|
||||||
|
mapTool := NewLLMMapTool(model, "mock-map-llm")
|
||||||
|
mapTool.SetRuntime(runtime)
|
||||||
|
readTool := NewMapRunReadTool(runtime)
|
||||||
|
|
||||||
|
result := decodeResultMap(t, mapTool.Execute(context.Background(), map[string]interface{}{
|
||||||
|
"instruction": "normalize",
|
||||||
|
"items": []interface{}{map[string]interface{}{"name": "retry"}},
|
||||||
|
"execution_mode": "inline",
|
||||||
|
"max_retries": float64(2),
|
||||||
|
}))
|
||||||
|
assert.Equal(t, mapRunStatusSucceeded, result["status"])
|
||||||
|
runID, _ := result["run_id"].(string)
|
||||||
|
require.NotEmpty(t, runID)
|
||||||
|
|
||||||
|
read := decodeResultMap(t, readTool.Execute(context.Background(), map[string]interface{}{
|
||||||
|
"run_id": runID,
|
||||||
|
"format": "json",
|
||||||
|
}))
|
||||||
|
itemsAny, ok := read["items"].([]interface{})
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Len(t, itemsAny, 1)
|
||||||
|
item0 := itemsAny[0].(map[string]interface{})
|
||||||
|
assert.EqualValues(t, 2, item0["attempts"])
|
||||||
|
assert.Equal(t, mapItemStatusSucceeded, item0["status"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLLMMap_InlineExhaustedRetriesFailsRun(t *testing.T) {
|
||||||
|
model := &scriptedMapLLM{
|
||||||
|
responses: []scriptedMapLLMResponse{
|
||||||
|
{Text: `not-json`},
|
||||||
|
{Text: `still-not-json`},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
runtime := newMapRuntimeForTest(t, model, nil)
|
||||||
|
mapTool := NewLLMMapTool(model, "mock-map-llm")
|
||||||
|
mapTool.SetRuntime(runtime)
|
||||||
|
|
||||||
|
payload := decodeResultMap(t, mapTool.Execute(context.Background(), map[string]interface{}{
|
||||||
|
"instruction": "normalize",
|
||||||
|
"items": []interface{}{map[string]interface{}{"name": "bad"}},
|
||||||
|
"execution_mode": "inline",
|
||||||
|
"max_retries": float64(1),
|
||||||
|
}))
|
||||||
|
assert.Equal(t, mapRunStatusFailed, payload["status"])
|
||||||
|
summary, ok := payload["summary"].(map[string]interface{})
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.EqualValues(t, 1, summary["failure_count"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMapRunRead_DetectsOutputHashMismatch(t *testing.T) {
|
||||||
|
model := &scriptedMapLLM{
|
||||||
|
responses: []scriptedMapLLMResponse{
|
||||||
|
{Text: `{"label":"alpha"}`},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
runtime := newMapRuntimeForTest(t, model, nil)
|
||||||
|
mapTool := NewLLMMapTool(model, "mock-map-llm")
|
||||||
|
mapTool.SetRuntime(runtime)
|
||||||
|
readTool := NewMapRunReadTool(runtime)
|
||||||
|
|
||||||
|
result := decodeResultMap(t, mapTool.Execute(context.Background(), map[string]interface{}{
|
||||||
|
"instruction": "normalize",
|
||||||
|
"items": []interface{}{map[string]interface{}{"name": "a"}},
|
||||||
|
"execution_mode": "inline",
|
||||||
|
}))
|
||||||
|
runIDText, _ := result["run_id"].(string)
|
||||||
|
require.NotEmpty(t, runIDText)
|
||||||
|
|
||||||
|
runID, err := ids.Parse(runIDText)
|
||||||
|
require.NoError(t, err)
|
||||||
|
rows, err := runtime.queries.ListMapItemsByRunPaged(context.Background(), memsqlc.ListMapItemsByRunPagedParams{
|
||||||
|
RunID: runID,
|
||||||
|
Lim: 10,
|
||||||
|
Off: 0,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, rows, 1)
|
||||||
|
|
||||||
|
_, err = runtime.queries.MarkMapItemSucceeded(context.Background(), memsqlc.MarkMapItemSucceededParams{
|
||||||
|
OutputFb: rows[0].OutputFb,
|
||||||
|
OutputHash: optionalStringPtr("forced-mismatch"),
|
||||||
|
ID: rows[0].ID,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
readResult := readTool.Execute(context.Background(), map[string]interface{}{
|
||||||
|
"run_id": runIDText,
|
||||||
|
"format": "json",
|
||||||
|
})
|
||||||
|
require.NotNil(t, readResult)
|
||||||
|
require.True(t, readResult.IsError)
|
||||||
|
assert.Contains(t, readResult.ForLLM, "output hash mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgenticMap_WorkerLifecycle(t *testing.T) {
|
||||||
|
provider := &MockLanguageModel{}
|
||||||
|
manager := NewSubagentManager(provider, "test-model", "/tmp/test", bus.NewMessageBus())
|
||||||
|
manager.SetRunLoop(func(_ context.Context, _ ToolLoopConfig, _, userPrompt, _, _ string) (*ToolLoopResult, error) {
|
||||||
|
return &ToolLoopResult{Content: "processed: " + userPrompt, Iterations: 1}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
runtime := newMapRuntimeForTest(t, &scriptedMapLLM{responses: []scriptedMapLLMResponse{{Text: `{"unused":true}`}}}, manager)
|
||||||
|
tool := NewAgenticMapTool(manager)
|
||||||
|
tool.SetRuntime(runtime)
|
||||||
|
statusTool := NewMapRunStatusTool(runtime)
|
||||||
|
readTool := NewMapRunReadTool(runtime)
|
||||||
|
|
||||||
|
enqueue := decodeResultMap(t, tool.Execute(context.Background(), map[string]interface{}{
|
||||||
|
"items": []interface{}{map[string]interface{}{"name": "x"}},
|
||||||
|
"task_template": "Handle {{index}} => {{item_json}}",
|
||||||
|
"execution_mode": "worker",
|
||||||
|
}))
|
||||||
|
runID, _ := enqueue["run_id"].(string)
|
||||||
|
require.NotEmpty(t, runID)
|
||||||
|
|
||||||
|
status := decodeResultMap(t, statusTool.Execute(context.Background(), map[string]interface{}{
|
||||||
|
"run_id": runID,
|
||||||
|
"process_steps": float64(20),
|
||||||
|
}))
|
||||||
|
assert.Equal(t, mapRunStatusSucceeded, status["status"])
|
||||||
|
|
||||||
|
read := decodeResultMap(t, readTool.Execute(context.Background(), map[string]interface{}{
|
||||||
|
"run_id": runID,
|
||||||
|
"format": "json",
|
||||||
|
}))
|
||||||
|
itemsAny, ok := read["items"].([]interface{})
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Len(t, itemsAny, 1)
|
||||||
|
item0 := itemsAny[0].(map[string]interface{})
|
||||||
|
assert.Equal(t, mapItemStatusSucceeded, item0["status"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLLMMap_InvalidJSONLIngestFails(t *testing.T) {
|
||||||
|
model := &scriptedMapLLM{
|
||||||
|
responses: []scriptedMapLLMResponse{{Text: `{"ok":true}`}},
|
||||||
|
}
|
||||||
|
runtime := newMapRuntimeForTest(t, model, nil)
|
||||||
|
mapTool := NewLLMMapTool(model, "mock-map-llm")
|
||||||
|
mapTool.SetRuntime(runtime)
|
||||||
|
|
||||||
|
result := mapTool.Execute(context.Background(), map[string]interface{}{
|
||||||
|
"instruction": "normalize",
|
||||||
|
"input_jsonl": "{\"name\":\"ok\"}\nnot-json",
|
||||||
|
})
|
||||||
|
require.NotNil(t, result)
|
||||||
|
require.True(t, result.IsError)
|
||||||
|
assert.Contains(t, result.ForLLM, "invalid JSONL")
|
||||||
|
}
|
||||||
93
pkg/tools/mapopsfb/MapItemInput.go
Normal file
93
pkg/tools/mapopsfb/MapItemInput.go
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
|
||||||
|
|
||||||
|
package mapopsfb
|
||||||
|
|
||||||
|
import (
|
||||||
|
flatbuffers "github.com/google/flatbuffers/go"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MapItemInput struct {
|
||||||
|
_tab flatbuffers.Table
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetRootAsMapItemInput(buf []byte, offset flatbuffers.UOffsetT) *MapItemInput {
|
||||||
|
n := flatbuffers.GetUOffsetT(buf[offset:])
|
||||||
|
x := &MapItemInput{}
|
||||||
|
x.Init(buf, n+offset)
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetSizePrefixedRootAsMapItemInput(buf []byte, offset flatbuffers.UOffsetT) *MapItemInput {
|
||||||
|
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
|
||||||
|
x := &MapItemInput{}
|
||||||
|
x.Init(buf, n+offset+flatbuffers.SizeUint32)
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *MapItemInput) Init(buf []byte, i flatbuffers.UOffsetT) {
|
||||||
|
rcv._tab.Bytes = buf
|
||||||
|
rcv._tab.Pos = i
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *MapItemInput) Table() flatbuffers.Table {
|
||||||
|
return rcv._tab
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *MapItemInput) Version() uint16 {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.GetUint16(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *MapItemInput) MutateVersion(n uint16) bool {
|
||||||
|
return rcv._tab.MutateUint16Slot(4, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *MapItemInput) ItemIndex() uint32 {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.GetUint32(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *MapItemInput) MutateItemIndex(n uint32) bool {
|
||||||
|
return rcv._tab.MutateUint32Slot(6, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *MapItemInput) ItemJson() []byte {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *MapItemInput) InputHash() []byte {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(10))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func MapItemInputStart(builder *flatbuffers.Builder) {
|
||||||
|
builder.StartObject(4)
|
||||||
|
}
|
||||||
|
func MapItemInputAddVersion(builder *flatbuffers.Builder, version uint16) {
|
||||||
|
builder.PrependUint16Slot(0, version, 1)
|
||||||
|
}
|
||||||
|
func MapItemInputAddItemIndex(builder *flatbuffers.Builder, itemIndex uint32) {
|
||||||
|
builder.PrependUint32Slot(1, itemIndex, 0)
|
||||||
|
}
|
||||||
|
func MapItemInputAddItemJson(builder *flatbuffers.Builder, itemJson flatbuffers.UOffsetT) {
|
||||||
|
builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(itemJson), 0)
|
||||||
|
}
|
||||||
|
func MapItemInputAddInputHash(builder *flatbuffers.Builder, inputHash flatbuffers.UOffsetT) {
|
||||||
|
builder.PrependUOffsetTSlot(3, flatbuffers.UOffsetT(inputHash), 0)
|
||||||
|
}
|
||||||
|
func MapItemInputEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||||
|
return builder.EndObject()
|
||||||
|
}
|
||||||
134
pkg/tools/mapopsfb/MapItemOutput.go
Normal file
134
pkg/tools/mapopsfb/MapItemOutput.go
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
|
||||||
|
|
||||||
|
package mapopsfb
|
||||||
|
|
||||||
|
import (
|
||||||
|
flatbuffers "github.com/google/flatbuffers/go"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MapItemOutput struct {
|
||||||
|
_tab flatbuffers.Table
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetRootAsMapItemOutput(buf []byte, offset flatbuffers.UOffsetT) *MapItemOutput {
|
||||||
|
n := flatbuffers.GetUOffsetT(buf[offset:])
|
||||||
|
x := &MapItemOutput{}
|
||||||
|
x.Init(buf, n+offset)
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetSizePrefixedRootAsMapItemOutput(buf []byte, offset flatbuffers.UOffsetT) *MapItemOutput {
|
||||||
|
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
|
||||||
|
x := &MapItemOutput{}
|
||||||
|
x.Init(buf, n+offset+flatbuffers.SizeUint32)
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *MapItemOutput) Init(buf []byte, i flatbuffers.UOffsetT) {
|
||||||
|
rcv._tab.Bytes = buf
|
||||||
|
rcv._tab.Pos = i
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *MapItemOutput) Table() flatbuffers.Table {
|
||||||
|
return rcv._tab
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *MapItemOutput) Version() uint16 {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.GetUint16(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *MapItemOutput) MutateVersion(n uint16) bool {
|
||||||
|
return rcv._tab.MutateUint16Slot(4, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *MapItemOutput) ItemIndex() uint32 {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.GetUint32(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *MapItemOutput) MutateItemIndex(n uint32) bool {
|
||||||
|
return rcv._tab.MutateUint32Slot(6, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *MapItemOutput) Success() bool {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.GetBool(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *MapItemOutput) MutateSuccess(n bool) bool {
|
||||||
|
return rcv._tab.MutateBoolSlot(8, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *MapItemOutput) Attempts() uint16 {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(10))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.GetUint16(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *MapItemOutput) MutateAttempts(n uint16) bool {
|
||||||
|
return rcv._tab.MutateUint16Slot(10, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *MapItemOutput) OutputJson() []byte {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(12))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *MapItemOutput) ErrorText() []byte {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(14))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *MapItemOutput) OutputHash() []byte {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(16))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func MapItemOutputStart(builder *flatbuffers.Builder) {
|
||||||
|
builder.StartObject(7)
|
||||||
|
}
|
||||||
|
func MapItemOutputAddVersion(builder *flatbuffers.Builder, version uint16) {
|
||||||
|
builder.PrependUint16Slot(0, version, 1)
|
||||||
|
}
|
||||||
|
func MapItemOutputAddItemIndex(builder *flatbuffers.Builder, itemIndex uint32) {
|
||||||
|
builder.PrependUint32Slot(1, itemIndex, 0)
|
||||||
|
}
|
||||||
|
func MapItemOutputAddSuccess(builder *flatbuffers.Builder, success bool) {
|
||||||
|
builder.PrependBoolSlot(2, success, false)
|
||||||
|
}
|
||||||
|
func MapItemOutputAddAttempts(builder *flatbuffers.Builder, attempts uint16) {
|
||||||
|
builder.PrependUint16Slot(3, attempts, 0)
|
||||||
|
}
|
||||||
|
func MapItemOutputAddOutputJson(builder *flatbuffers.Builder, outputJson flatbuffers.UOffsetT) {
|
||||||
|
builder.PrependUOffsetTSlot(4, flatbuffers.UOffsetT(outputJson), 0)
|
||||||
|
}
|
||||||
|
func MapItemOutputAddErrorText(builder *flatbuffers.Builder, errorText flatbuffers.UOffsetT) {
|
||||||
|
builder.PrependUOffsetTSlot(5, flatbuffers.UOffsetT(errorText), 0)
|
||||||
|
}
|
||||||
|
func MapItemOutputAddOutputHash(builder *flatbuffers.Builder, outputHash flatbuffers.UOffsetT) {
|
||||||
|
builder.PrependUOffsetTSlot(6, flatbuffers.UOffsetT(outputHash), 0)
|
||||||
|
}
|
||||||
|
func MapItemOutputEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||||
|
return builder.EndObject()
|
||||||
|
}
|
||||||
159
pkg/tools/mapopsfb/MapRunSpec.go
Normal file
159
pkg/tools/mapopsfb/MapRunSpec.go
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
|
||||||
|
|
||||||
|
package mapopsfb
|
||||||
|
|
||||||
|
import (
|
||||||
|
flatbuffers "github.com/google/flatbuffers/go"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MapRunSpec struct {
|
||||||
|
_tab flatbuffers.Table
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetRootAsMapRunSpec(buf []byte, offset flatbuffers.UOffsetT) *MapRunSpec {
|
||||||
|
n := flatbuffers.GetUOffsetT(buf[offset:])
|
||||||
|
x := &MapRunSpec{}
|
||||||
|
x.Init(buf, n+offset)
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetSizePrefixedRootAsMapRunSpec(buf []byte, offset flatbuffers.UOffsetT) *MapRunSpec {
|
||||||
|
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
|
||||||
|
x := &MapRunSpec{}
|
||||||
|
x.Init(buf, n+offset+flatbuffers.SizeUint32)
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *MapRunSpec) Init(buf []byte, i flatbuffers.UOffsetT) {
|
||||||
|
rcv._tab.Bytes = buf
|
||||||
|
rcv._tab.Pos = i
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *MapRunSpec) Table() flatbuffers.Table {
|
||||||
|
return rcv._tab
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *MapRunSpec) Version() uint16 {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.GetUint16(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *MapRunSpec) MutateVersion(n uint16) bool {
|
||||||
|
return rcv._tab.MutateUint16Slot(4, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *MapRunSpec) OperatorKind() []byte {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *MapRunSpec) Instruction() []byte {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *MapRunSpec) TaskTemplate() []byte {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(10))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *MapRunSpec) OutputSchemaJson() []byte {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(12))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *MapRunSpec) MaxRetries() uint16 {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(14))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.GetUint16(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *MapRunSpec) MutateMaxRetries(n uint16) bool {
|
||||||
|
return rcv._tab.MutateUint16Slot(14, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *MapRunSpec) DelegatedScope() []byte {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(16))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *MapRunSpec) KeptWork() []byte {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(18))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *MapRunSpec) OriginChannel() []byte {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(20))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *MapRunSpec) OriginChatId() []byte {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(22))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func MapRunSpecStart(builder *flatbuffers.Builder) {
|
||||||
|
builder.StartObject(10)
|
||||||
|
}
|
||||||
|
func MapRunSpecAddVersion(builder *flatbuffers.Builder, version uint16) {
|
||||||
|
builder.PrependUint16Slot(0, version, 1)
|
||||||
|
}
|
||||||
|
func MapRunSpecAddOperatorKind(builder *flatbuffers.Builder, operatorKind flatbuffers.UOffsetT) {
|
||||||
|
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(operatorKind), 0)
|
||||||
|
}
|
||||||
|
func MapRunSpecAddInstruction(builder *flatbuffers.Builder, instruction flatbuffers.UOffsetT) {
|
||||||
|
builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(instruction), 0)
|
||||||
|
}
|
||||||
|
func MapRunSpecAddTaskTemplate(builder *flatbuffers.Builder, taskTemplate flatbuffers.UOffsetT) {
|
||||||
|
builder.PrependUOffsetTSlot(3, flatbuffers.UOffsetT(taskTemplate), 0)
|
||||||
|
}
|
||||||
|
func MapRunSpecAddOutputSchemaJson(builder *flatbuffers.Builder, outputSchemaJson flatbuffers.UOffsetT) {
|
||||||
|
builder.PrependUOffsetTSlot(4, flatbuffers.UOffsetT(outputSchemaJson), 0)
|
||||||
|
}
|
||||||
|
func MapRunSpecAddMaxRetries(builder *flatbuffers.Builder, maxRetries uint16) {
|
||||||
|
builder.PrependUint16Slot(5, maxRetries, 1)
|
||||||
|
}
|
||||||
|
func MapRunSpecAddDelegatedScope(builder *flatbuffers.Builder, delegatedScope flatbuffers.UOffsetT) {
|
||||||
|
builder.PrependUOffsetTSlot(6, flatbuffers.UOffsetT(delegatedScope), 0)
|
||||||
|
}
|
||||||
|
func MapRunSpecAddKeptWork(builder *flatbuffers.Builder, keptWork flatbuffers.UOffsetT) {
|
||||||
|
builder.PrependUOffsetTSlot(7, flatbuffers.UOffsetT(keptWork), 0)
|
||||||
|
}
|
||||||
|
func MapRunSpecAddOriginChannel(builder *flatbuffers.Builder, originChannel flatbuffers.UOffsetT) {
|
||||||
|
builder.PrependUOffsetTSlot(8, flatbuffers.UOffsetT(originChannel), 0)
|
||||||
|
}
|
||||||
|
func MapRunSpecAddOriginChatId(builder *flatbuffers.Builder, originChatId flatbuffers.UOffsetT) {
|
||||||
|
builder.PrependUOffsetTSlot(9, flatbuffers.UOffsetT(originChatId), 0)
|
||||||
|
}
|
||||||
|
func MapRunSpecEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||||
|
return builder.EndObject()
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue