diff --git a/internal/assistant/execute_request.go b/internal/assistant/execute_request.go new file mode 100644 index 00000000..a6247ebc --- /dev/null +++ b/internal/assistant/execute_request.go @@ -0,0 +1,273 @@ +package assistant + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "strings" + "unicode/utf8" + + "github.com/samber/oops" + + "github.com/omarluq/librecode/internal/tool" +) + +const ( + executeArgumentsKey = "arguments" + maxExecuteArgumentsSize = 64 << 10 + maxExecuteNameSize = 80 +) + +// executeToolInput is the single provider-facing request contract. Presence is +// retained separately so omitted fields can be distinguished from zero values. +type executeToolInput struct { + Source string `json:"source"` + Profile MVMExecutionProfile `json:"profile,omitempty"` + Name string `json:"name,omitempty"` + Arguments json.RawMessage `json:"arguments,omitempty"` + Limits json.RawMessage `json:"limits,omitempty"` + OutputSchema json.RawMessage `json:"output_schema,omitempty"` + + hasName bool + hasArguments bool + hasLimits bool + hasOutputSchema bool +} + +func decodeExecuteToolInput(input tool.Arguments, durableAvailable bool) (executeToolInput, error) { + fields, err := input.Fields() + if err != nil { + return executeToolInput{}, executeRequestError("execute_input", err, "decode execute input") + } + + if err := validateExecuteFieldNames(fields); err != nil { + return executeToolInput{}, err + } + + request := executeToolInput{ + Source: "", Profile: MVMExecutionProfileTurn, Name: "", Arguments: nil, Limits: nil, + OutputSchema: nil, hasName: fields[executeNameKey] != nil, + hasArguments: fields[executeArgumentsKey] != nil, hasLimits: fields["limits"] != nil, + hasOutputSchema: fields["output_schema"] != nil, + } + if err := decodeExecuteScalars(fields, &request); err != nil { + return request, err + } + + if err := validateExecuteSourceAndName(&request); err != nil { + return request, err + } + + if err := decodeExecuteObjects(fields, &request); err != nil { + return request, err + } + + if err := validateExecuteProfile(&request, durableAvailable); err != nil { + return request, err + } + + return request, nil +} + +func validateExecuteFieldNames(fields map[string]json.RawMessage) error { + for name := range fields { + switch name { + case "source", "profile", executeNameKey, executeArgumentsKey, "limits", "output_schema": + default: + return oops.In("assistant").Code("execute_input_field_unsupported"). + Errorf("execute input field %q is unsupported", name) + } + } + + return nil +} + +func decodeExecuteScalars(fields map[string]json.RawMessage, request *executeToolInput) error { + if raw, ok := fields["source"]; ok { + if err := json.Unmarshal(raw, &request.Source); err != nil { + return executeRequestError("execute_input", err, "decode execute source") + } + } + + if raw, ok := fields["profile"]; ok { + if err := json.Unmarshal(raw, &request.Profile); err != nil { + return executeRequestError("execute_profile_invalid", err, "execute profile must be a string") + } + } + + if raw, ok := fields[executeNameKey]; ok { + if err := json.Unmarshal(raw, &request.Name); err != nil { + return executeRequestError("execute_input", err, "decode execute name") + } + } + + return nil +} + +func validateExecuteSourceAndName(request *executeToolInput) error { + if strings.TrimSpace(request.Source) == "" { + return oops.In("assistant").Code("execute_source_required"). + Errorf("execute source is required") + } + + if !utf8.ValidString(request.Source) { + return oops.In("assistant").Code("execute_source_invalid_utf8"). + Errorf("execute source must be valid UTF-8") + } + + request.Name = strings.TrimSpace(request.Name) + if request.hasName && request.Name == "" { + return oops.In("assistant").Code("execute_name_required"). + Errorf("execute name must not be blank") + } + + if !utf8.ValidString(request.Name) { + return oops.In("assistant").Code("execute_name_invalid_utf8"). + Errorf("execute name must be valid UTF-8") + } + + if len(request.Name) > maxExecuteNameSize { + return oops.In("assistant").Code("execute_name_limit"). + Errorf("execute name is %d bytes; limit is %d", len(request.Name), maxExecuteNameSize) + } + + return nil +} + +func decodeExecuteObjects(fields map[string]json.RawMessage, request *executeToolInput) error { + if request.hasArguments { + arguments, err := canonicalExecuteJSONObject(fields[executeArgumentsKey], "execute_arguments_invalid") + if err != nil { + return err + } + + if len(arguments) > maxExecuteArgumentsSize { + return oops.In("assistant").Code("execute_arguments_limit").Errorf( + "canonical execute arguments are %d bytes; limit is %d", + len(arguments), maxExecuteArgumentsSize, + ) + } + + request.Arguments = arguments + } + + if request.hasLimits { + limits, err := canonicalExecuteJSONObject(fields["limits"], "execute_limits_invalid") + if err != nil { + return err + } + + if !bytes.Equal(limits, []byte("{}")) { + return oops.In("assistant").Code("execute_limit_unsupported"). + Errorf("execute limits do not currently accept any fields") + } + + request.Limits = limits + } + + if request.hasOutputSchema { + return oops.In("assistant").Code("execute_output_schema_unsupported"). + Errorf("execute output_schema is not supported yet") + } + + return nil +} + +func validateExecuteProfile(request *executeToolInput, durableAvailable bool) error { + if request.Profile != MVMExecutionProfileTurn && request.Profile != MVMExecutionProfileDurable { + return oops.In("assistant").Code("execute_profile_invalid"). + Errorf("execute profile %q is invalid; expected turn or durable", request.Profile) + } + + if request.Profile == MVMExecutionProfileTurn { + if request.hasName { + return oops.In("assistant").Code("execute_turn_name_unsupported"). + Errorf("execute name is only valid for the durable profile") + } + + if request.hasArguments { + return oops.In("assistant").Code("execute_turn_arguments_unsupported"). + Errorf("execute arguments are only valid for the durable profile") + } + + return nil + } + + if !durableAvailable { + return oops.In("assistant").Code("execute_durable_unavailable"). + Errorf("durable execute is unavailable because no workflow submitter is configured") + } + + if request.Name == "" { + request.Name = deriveExecuteName(request.Source) + } + + return nil +} + +// canonicalExecuteJSONObject emits compact JSON with lexicographically sorted +// object keys. json.Number retains submitted number spelling until the shared +// numeric semantics required by Phase 2 are specified. +func canonicalExecuteJSONObject(raw json.RawMessage, code string) (json.RawMessage, error) { + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + + var object map[string]any + if err := decoder.Decode(&object); err != nil { + return nil, executeRequestError(code, err, "decode execute object") + } + + if object == nil { + return nil, oops.In("assistant").Code(code). + Errorf("execute value must be a JSON object") + } + + if err := ensureJSONEOF(decoder); err != nil { + return nil, executeRequestError(code, err, "decode execute object") + } + + encoded, err := json.Marshal(object) + if err != nil { + return nil, executeRequestError(code, err, "encode canonical execute object") + } + + return encoded, nil +} + +func ensureJSONEOF(decoder *json.Decoder) error { + var extra any + if err := decoder.Decode(&extra); err != io.EOF { + if err == nil { + return errors.New("multiple JSON values") + } + + return executeRequestError("execute_json_trailing", err, "decode trailing execute JSON") + } + + return nil +} + +func deriveExecuteName(source string) string { + const splitFirstLine = 2 + + line := strings.TrimSpace(strings.SplitN(source, "\n", splitFirstLine)[0]) + if line == "" { + line = "Durable execution" + } + + if len(line) <= maxExecuteNameSize { + return line + } + + line = line[:maxExecuteNameSize] + for !utf8.ValidString(line) { + line = line[:len(line)-1] + } + + return strings.TrimSpace(line) +} + +func executeRequestError(code string, err error, message string) error { + return oops.In("assistant").Code(code).Wrapf(err, "%s", message) +} diff --git a/internal/assistant/execute_request_internal_test.go b/internal/assistant/execute_request_internal_test.go new file mode 100644 index 00000000..03895a91 --- /dev/null +++ b/internal/assistant/execute_request_internal_test.go @@ -0,0 +1,134 @@ +package assistant + +import ( + "strings" + "testing" + + "github.com/samber/oops" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/omarluq/librecode/internal/tool" +) + +const executeRequestTestName = "review" + +type executeRequestTestCase struct { + raw string + wantName string + wantArguments string + wantCode string + name string + wantProfile MVMExecutionProfile + durableAvailable bool +} + +func executeRequestFailure(name, raw, code string) executeRequestTestCase { + return executeRequestTestCase{ + raw: raw, wantName: "", wantArguments: "", wantCode: code, name: name, + wantProfile: "", durableAvailable: false, + } +} + +func TestDecodeExecuteToolInput(t *testing.T) { + t.Parallel() + + tests := []executeRequestTestCase{ + { + raw: `{"source":" 1 + 1 "}`, wantName: "", wantArguments: "", wantCode: "", + name: "turn defaults", wantProfile: MVMExecutionProfileTurn, durableAvailable: false, + }, + { + raw: `{"source":"1","profile":"durable","name":" review ",` + + `"arguments":{"z":2,"a":1},"limits":{}}`, + wantName: executeRequestTestName, wantArguments: `{"a":1,"z":2}`, wantCode: "", + name: "durable canonical arguments", wantProfile: MVMExecutionProfileDurable, durableAvailable: true, + }, + { + raw: `{"source":"` + strings.Repeat("x", maxExecuteNameSize+20) + `\n1","profile":"durable"}`, + wantName: strings.Repeat("x", maxExecuteNameSize), wantArguments: "", wantCode: "", + name: "durable derived bounded name", wantProfile: MVMExecutionProfileDurable, durableAvailable: true, + }, + executeRequestFailure("unknown profile", `{"source":"1","profile":"fast"}`, "execute_profile_invalid"), + executeRequestFailure( + "durable unavailable", `{"source":"1","profile":"durable"}`, "execute_durable_unavailable", + ), + executeRequestFailure("turn name", `{"source":"1","name":"named"}`, "execute_turn_name_unsupported"), + executeRequestFailure( + "turn arguments", `{"source":"1","arguments":{}}`, "execute_turn_arguments_unsupported", + ), + executeRequestFailure( + "unsupported limit", `{"source":"1","limits":{"timeout":1}}`, "execute_limit_unsupported", + ), + executeRequestFailure( + "output schema reserved", `{"source":"1","output_schema":{}}`, "execute_output_schema_unsupported", + ), + executeRequestFailure( + "unknown field", `{"source":"1","future":true}`, "execute_input_field_unsupported", + ), + { + raw: `{"source":"1","profile":"durable","arguments":[]}`, wantName: "", wantArguments: "", + wantCode: "execute_arguments_invalid", name: "arguments must be object", wantProfile: "", + durableAvailable: true, + }, + { + raw: `{"source":"1","profile":"durable","arguments":{"value":"` + + strings.Repeat("x", maxExecuteArgumentsSize) + `"}}`, + wantName: "", wantArguments: "", wantCode: "execute_arguments_limit", name: "arguments byte limit", + wantProfile: "", durableAvailable: true, + }, + executeRequestFailure("blank source", `{"source":" "}`, "execute_source_required"), + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + arguments, err := tool.ArgumentsFromRaw([]byte(test.raw)) + require.NoError(t, err) + + request, err := decodeExecuteToolInput(arguments, test.durableAvailable) + if test.wantCode != "" { + require.Error(t, err) + coded, ok := oops.AsOops(err) + require.True(t, ok) + assert.Equal(t, test.wantCode, coded.Code()) + + return + } + + require.NoError(t, err) + assert.Equal(t, test.wantProfile, request.Profile) + assert.Equal(t, test.wantName, request.Name) + + if test.wantArguments != "" { + assert.JSONEq(t, test.wantArguments, string(request.Arguments)) + } + }) + } +} + +func TestDecodeExecuteToolInputPreservesRequestedProfileOnRejection(t *testing.T) { + t.Parallel() + + arguments, err := tool.ArgumentsFromRaw([]byte(`{"source":"1","profile":"durable"}`)) + require.NoError(t, err) + + request, err := decodeExecuteToolInput(arguments, false) + require.Error(t, err) + assert.Equal(t, MVMExecutionProfileDurable, request.Profile) +} + +func TestExecuteDefinitionPublishesUnifiedRequestContract(t *testing.T) { + t.Parallel() + + definition := newExecuteTool(nil, nil).Definition() + raw := string(definition.Schema.RawMessage()) + + assert.Contains(t, raw, `"enum":["turn","durable"]`) + assert.Contains(t, raw, `"default":"turn"`) + assert.Contains(t, raw, `"arguments"`) + assert.Contains(t, raw, `"limits"`) + assert.Contains(t, raw, `"output_schema"`) + assert.Contains(t, raw, "Imports do not select") +} diff --git a/internal/assistant/execute_tool.go b/internal/assistant/execute_tool.go index e21557f8..ed48fe58 100644 --- a/internal/assistant/execute_tool.go +++ b/internal/assistant/execute_tool.go @@ -3,6 +3,7 @@ package assistant import ( "context" "encoding/json" + "errors" "strings" "github.com/samber/oops" @@ -16,10 +17,9 @@ import ( const executeToolName tool.Name = "execute" const ( - defaultExecuteResultLimit = 1 << 20 - executeNameKey = "name" - executeResultValueKey = "result_value" - executeCallMethod = "call" + executeNameKey = "name" + executeResultValueKey = "result_value" + executeCallMethod = "call" ) type nestedToolInvoker func(context.Context, string, tool.Arguments, string) (tool.Result, ToolEvent) @@ -29,10 +29,6 @@ type executeToolExecutor struct { invoke nestedToolInvoker } -type executeToolInput struct { - Source string `json:"source"` -} - type executeToolCallResult = executeworker.ToolCallResult func newExecuteTool(registry *tool.Registry, invoke nestedToolInvoker) *executeToolExecutor { @@ -41,11 +37,41 @@ func newExecuteTool(registry *tool.Registry, invoke nestedToolInvoker) *executeT func (executor *executeToolExecutor) Definition() tool.Definition { return tool.Definition{ - Schema: mustToolSchema( - `{"type":"object","additionalProperties":false,"properties":` + - `{"source":{"type":"string","description":"Go source to evaluate with the tools package."}},` + - `"required":["source"]}`, - ), + Schema: mustToolSchema(`{ + "type":"object", + "additionalProperties":false, + "properties":{ + "source":{ + "type":"string", + "description":"Go source to evaluate. Imports do not select the execution profile." + }, + "profile":{ + "type":"string", + "enum":["turn","durable"], + "default":"turn", + "description":"Execution guarantees; omitted defaults to turn." + }, + "name":{ + "type":"string", + "description":"Optional durable display name, limited to 80 UTF-8 bytes; not execution identity." + }, + "arguments":{ + "type":"object", + "description":"Optional durable JSON values; canonical encoding is limited to 65536 bytes." + }, + "limits":{ + "type":"object", + "additionalProperties":false, + "maxProperties":0, + "description":"Reserved; no execution limits are accepted initially." + }, + "output_schema":{ + "type":"object", + "description":"Reserved structured-output schema; not supported initially." + } + }, + "required":["source"] + }`), Name: executeToolName, Label: "Execute Go", Description: "Evaluate Go source that can search, describe, and call the tools available for this prompt.", @@ -58,15 +84,26 @@ func (executor *executeToolExecutor) Definition() tool.Definition { } } +func executeRequestProfile(args *executeToolInput) MVMExecutionProfile { + if args.Profile == "" { + return MVMExecutionProfileTurn + } + + return args.Profile +} + func (executor *executeToolExecutor) Execute(ctx context.Context, input tool.Arguments) (tool.Result, error) { + args, decodeErr := decodeExecuteToolInput(input, false) + profile := executeRequestProfile(&args) + if executor.registry == nil { - return tool.Result{}, oops.In("assistant").Code("execute_registry_missing"). - Errorf("execute tool registry is not configured") + return tool.TextResult("", executionResultDetails(nil, profile, ExecutionResultRejected)), + oops.In("assistant").Code("execute_registry_missing"). + Errorf("execute tool registry is not configured") } - var args executeToolInput - if err := input.Decode(&args); err != nil { - return tool.Result{}, oops.In("assistant").Code("execute_input").Wrapf(err, "decode execute input") + if decodeErr != nil { + return tool.TextResult("", executionResultDetails(nil, args.Profile, ExecutionResultRejected)), decodeErr } client := executeworker.Client{Executable: "", Handler: executor.handleWorkerMessage} @@ -75,7 +112,14 @@ func (executor *executeToolExecutor) Execute(ctx context.Context, input tool.Arg if err != nil { wrapped := oops.In("assistant").Code("execute_source").Wrapf(err, "execute MVM source") - return tool.TextResult("", executeResultDetails(result)), wrapped + kind := ExecutionResultFailed + if errors.Is(err, context.Canceled) { + kind = ExecutionResultCanceled + } else if errors.Is(err, context.DeadlineExceeded) { + kind = ExecutionResultTimedOut + } + + return tool.TextResult("", executeResultDetails(result, kind)), wrapped } if nested, ok := result.Value.(executeworker.ToolCallResult); ok && !nested.IsError { @@ -84,18 +128,25 @@ func (executor *executeToolExecutor) Execute(ctx context.Context, input tool.Arg toolResult.Details = map[string]any{} } - toolResult.Details["execute_stdout"] = result.Stdout - toolResult.Details["execute_stderr"] = result.Stderr + toolResult.Details = executionResultDetails(map[string]any{ + executeResultValueKey: nil, + "stdout": result.Stdout, + "stderr": result.Stderr, + "content": nested.Content, + "tool_details": nested.Details, + }, MVMExecutionProfileTurn, ExecutionResultCompleted) - return toolResult, nil + return boundProviderVisibleExecutionResult(toolResult), nil } text, err := executeResultText(result) if err != nil { - return tool.Result{}, err + return tool.TextResult("", executeResultDetails(result, ExecutionResultFailed)), err } - return tool.TextResult(text, executeResultDetails(result)), nil + return boundProviderVisibleExecutionResult( + tool.TextResult(text, executeResultDetails(result, ExecutionResultCompleted)), + ), nil } func (executor *executeToolExecutor) handleWorkerMessage( @@ -260,24 +311,27 @@ func executeResultText(result mvmhost.Result) (string, error) { return "null", nil } - encoded, err := json.Marshal(result.Value) + text, _, _, _, err := encodeProviderVisibleExecutionValue(result.Value) + + return text, err +} + +func executeResultDetails(result mvmhost.Result, kind ExecutionResultKind) map[string]any { + _, resultValue, partialValue, truncation, err := encodeProviderVisibleExecutionValue(result.Value) if err != nil { - return "", oops.In("assistant").Code("execute_result_encode").Wrapf(err, "encode execute result") + resultValue = result.Value } - if len(encoded) > defaultExecuteResultLimit { - return "", oops.In("assistant").Code("execute_result_limit").Errorf( - "encoded execute result is %d bytes; limit is %d", - len(encoded), - defaultExecuteResultLimit, - ) + details := map[string]any{executeResultValueKey: resultValue, "stdout": result.Stdout, "stderr": result.Stderr} + if partialValue != nil { + details["partial_value"] = partialValue } - return string(encoded), nil -} + if truncation != nil { + details["truncation"] = truncation + } -func executeResultDetails(result mvmhost.Result) map[string]any { - return map[string]any{executeResultValueKey: result.Value, "stdout": result.Stdout, "stderr": result.Stderr} + return executionResultDetails(details, MVMExecutionProfileTurn, kind) } var _ tool.Executor = (*executeToolExecutor)(nil) diff --git a/internal/assistant/execute_tool_internal_test.go b/internal/assistant/execute_tool_internal_test.go index c0fc1404..6be6bd3e 100644 --- a/internal/assistant/execute_tool_internal_test.go +++ b/internal/assistant/execute_tool_internal_test.go @@ -72,6 +72,20 @@ func TestExecuteToolUsesCompletedPromptRegistry(t *testing.T) { t.Helper() assert.Contains(t, result.Text(), `"name":"echo"`) assert.NotContains(t, result.Text(), `"name":"execute"`) + assert.Equal(t, ExecutionResultCompleted, result.Details[executionResultKindKey]) + assert.Equal(t, MVMExecutionProfileTurn, result.Details[executionProfileKey]) + }, + }, + { + name: "single call preserves common envelope", + source: `import "tools"; tools.Call("echo", map[string]interface{}{"text": "one"})`, + check: func(t *testing.T, result tool.Result) { + t.Helper() + assert.Equal(t, "one", result.Text()) + assert.Empty(t, result.Details["stdout"]) + assert.Empty(t, result.Details["stderr"]) + assert.Equal(t, map[string]any{"length": float64(3)}, result.Details["tool_details"]) + assert.Equal(t, result.Content, result.Details["content"]) }, }, { @@ -311,15 +325,32 @@ func TestExecuteNestedCallCancellationEmitsCompletedBoundaries(t *testing.T) { assert.True(t, events[2].ToolEvent.IsError) } -func TestExecuteResultTextRejectsOversizedResult(t *testing.T) { +func TestExecuteResultTextDeterministicallyTruncatesOversizedResult(t *testing.T) { t.Parallel() - _, err := executeResultText(mvmhost.Result{ - Value: strings.Repeat("x", defaultExecuteResultLimit), ValueKind: "", Stdout: "", Stderr: "", - }) - require.Error(t, err) - assert.Contains(t, err.Error(), "execute result") - assert.Contains(t, err.Error(), "limit") + value := strings.Repeat("x", MaxProviderVisibleExecutionResultSize) + text, err := executeResultText(mvmhost.Result{Value: value, ValueKind: "", Stdout: "", Stderr: ""}) + require.NoError(t, err) + assert.Len(t, []byte(text), MaxProviderVisibleExecutionResultSize) + + details := executeResultDetails( + mvmhost.Result{Value: value, ValueKind: "", Stdout: "", Stderr: ""}, + ExecutionResultCompleted, + ) + assert.Nil(t, details[executeResultValueKey]) + assert.Equal(t, text, details["partial_value"]) + assert.Equal(t, &ExecutionTruncation{ + Field: "result_value", LimitBytes: MaxProviderVisibleExecutionResultSize, + OriginalBytes: MaxProviderVisibleExecutionResultSize + 2, + VisibleBytes: MaxProviderVisibleExecutionResultSize, OmittedBytes: 2, + }, details["truncation"]) + + bounded := boundProviderVisibleExecutionResult(tool.TextResult(text, details)) + assert.LessOrEqual(t, providerVisibleExecutionResultSize(bounded), MaxProviderVisibleExecutionResultSize) + truncation, ok := bounded.Details["truncation"].(*ExecutionTruncation) + require.True(t, ok) + assert.Equal(t, "provider_result", truncation.Field) + assert.Positive(t, truncation.OmittedBytes) } func TestExecuteToolValidationAndWorkerErrors(t *testing.T) { @@ -329,6 +360,12 @@ func TestExecuteToolValidationAndWorkerErrors(t *testing.T) { _, err := missingRegistry.Execute(t.Context(), tool.EmptyArguments()) require.ErrorContains(t, err, "registry is not configured") + durableInput, inputErr := tool.ArgumentsFromRaw([]byte(`{"source":"1","profile":"durable"}`)) + require.NoError(t, inputErr) + result, err := missingRegistry.Execute(t.Context(), durableInput) + require.ErrorContains(t, err, "registry is not configured") + assert.Equal(t, MVMExecutionProfileDurable, result.Details[executionProfileKey]) + registry, registryErr := tool.NewRegistryWithTools(t.TempDir(), nil) require.NoError(t, registryErr) @@ -337,7 +374,7 @@ func TestExecuteToolValidationAndWorkerErrors(t *testing.T) { invalidInput, inputErr := tool.ArgumentsFromRaw([]byte(`{"source":42}`)) require.NoError(t, inputErr) _, err = execute.Execute(t.Context(), invalidInput) - require.ErrorContains(t, err, "decode execute input") + require.ErrorContains(t, err, "decode execute") _, err = execute.handleWorkerMessage(t.Context(), executeTestWorkerMessage("unknown", "", nil)) require.ErrorContains(t, err, "unknown execute worker RPC method") diff --git a/internal/assistant/execution_result.go b/internal/assistant/execution_result.go new file mode 100644 index 00000000..0ad722fe --- /dev/null +++ b/internal/assistant/execution_result.go @@ -0,0 +1,235 @@ +package assistant + +import ( + "encoding/json" + "strings" + "unicode/utf8" + + "github.com/samber/oops" + + "github.com/omarluq/librecode/internal/tool" +) + +// MVMExecutionProfile identifies the guarantees selected for an MVM execution. +type MVMExecutionProfile string + +const ( + // MVMExecutionProfileTurn selects synchronous provider-turn execution. + MVMExecutionProfileTurn MVMExecutionProfile = "turn" + // MVMExecutionProfileDurable selects detached persisted workflow execution. + MVMExecutionProfileDurable MVMExecutionProfile = "durable" +) + +// ExecutionResultKind discriminates the terminal or acceptance outcome of an +// MVM execution independently of the provider-facing tool name. +type ExecutionResultKind string + +const ( + // ExecutionResultCompleted indicates successful synchronous completion. + ExecutionResultCompleted ExecutionResultKind = "completed" + // ExecutionResultAccepted indicates accepted durable work. + ExecutionResultAccepted ExecutionResultKind = "accepted" + // ExecutionResultFailed indicates execution failed after admission. + ExecutionResultFailed ExecutionResultKind = "failed" + // ExecutionResultCanceled indicates execution was canceled. + ExecutionResultCanceled ExecutionResultKind = "canceled" + // ExecutionResultTimedOut indicates execution exceeded its deadline. + ExecutionResultTimedOut ExecutionResultKind = "timed_out" + // ExecutionResultRejected indicates the request was not admitted. + ExecutionResultRejected ExecutionResultKind = "rejected" +) + +const ( + executionResultKindKey = "result_kind" + executionIdentityKey = "execution" + executionProfileKey = "profile" + executionIdentityMVM = "mvm" + + // MaxProviderVisibleExecutionResultSize is the byte limit for an encoded + // execution value returned in provider-visible text. Other worker output is + // independently bounded by the worker protocol. + MaxProviderVisibleExecutionResultSize = 1 << 20 +) + +// ExecutionResultEnvelope defines the additive common result contract. Fields +// used only by later phases are optional so producers need not synthesize them. +type ExecutionResultEnvelope struct { + PartialValue any `json:"partial_value,omitempty"` + ResultValue any `json:"result_value,omitempty"` + ToolDetails map[string]any `json:"tool_details,omitempty"` + Truncation *ExecutionTruncation `json:"truncation,omitempty"` + Usage map[string]any `json:"usage,omitempty"` + RunID string `json:"run_id,omitempty"` + Stderr string `json:"stderr,omitempty"` + ResultKind ExecutionResultKind `json:"result_kind"` + WorkflowTaskID string `json:"workflow_task_id,omitempty"` + Kind string `json:"kind,omitempty"` + Name string `json:"name,omitempty"` + State string `json:"state,omitempty"` + Stdout string `json:"stdout,omitempty"` + Profile MVMExecutionProfile `json:"profile"` + Execution string `json:"execution"` + Content []tool.ContentBlock `json:"content,omitempty"` + Artifacts []map[string]any `json:"artifacts,omitempty"` + Warnings []string `json:"warnings,omitempty"` +} + +// ExecutionTruncation makes bounded projections deterministic and measurable. +type ExecutionTruncation struct { + Field string `json:"field"` + LimitBytes int `json:"limit_bytes"` + OriginalBytes int `json:"original_bytes"` + VisibleBytes int `json:"visible_bytes"` + OmittedBytes int `json:"omitted_bytes"` +} + +func executionResultDetails( + base map[string]any, + profile MVMExecutionProfile, + kind ExecutionResultKind, +) map[string]any { + if base == nil { + base = map[string]any{} + } + + base[executionResultKindKey] = kind + base[executionIdentityKey] = executionIdentityMVM + base[executionProfileKey] = profile + + return base +} + +func boundProviderVisibleExecutionResult(result tool.Result) tool.Result { + originalBytes := providerVisibleExecutionResultSize(result) + if originalBytes <= MaxProviderVisibleExecutionResultSize { + return result + } + + details := executionResultDetails( + map[string]any{executeResultValueKey: nil}, + profileFromExecutionDetails(result.Details), + resultKindFromExecutionDetails(result.Details), + ) + truncation := &ExecutionTruncation{ + Field: "provider_result", + LimitBytes: MaxProviderVisibleExecutionResultSize, + OriginalBytes: originalBytes, + VisibleBytes: 0, + OmittedBytes: originalBytes, + } + details["truncation"] = truncation + + bounded := tool.TextResult("", details) + + detailsBytes, err := json.Marshal(details) + if err != nil { + return bounded + } + + const ( + detailsSeparator = "\ndetails:\n" + truncationMetadataMargin = 64 + ) + + available := MaxProviderVisibleExecutionResultSize - len(detailsSeparator) - len(detailsBytes) - + truncationMetadataMargin + + text := result.Text() + if available > 0 { + text = truncateExecutionUTF8(text, available) + bounded = tool.TextResult(text, details) + } + + truncation.VisibleBytes = providerVisibleExecutionResultSize(bounded) + truncation.OmittedBytes = originalBytes - truncation.VisibleBytes + + return bounded +} + +func providerVisibleExecutionResultSize(result tool.Result) int { + text := strings.TrimSpace(result.Text()) + if len(result.Details) == 0 { + return len(text) + } + + details, err := json.Marshal(result.Details) + if err != nil { + return len(text) + } + + if text == "" { + return len("details:\n") + len(details) + } + + return len(text) + len("\ndetails:\n") + len(details) +} + +func profileFromExecutionDetails(details map[string]any) MVMExecutionProfile { + profile, ok := details[executionProfileKey].(MVMExecutionProfile) + if ok { + return profile + } + + if raw, rawOK := details[executionProfileKey].(string); rawOK { + return MVMExecutionProfile(raw) + } + + return MVMExecutionProfileTurn +} + +func resultKindFromExecutionDetails(details map[string]any) ExecutionResultKind { + kind, ok := details[executionResultKindKey].(ExecutionResultKind) + if ok { + return kind + } + + if raw, rawOK := details[executionResultKindKey].(string); rawOK { + return ExecutionResultKind(raw) + } + + return ExecutionResultFailed +} + +func truncateExecutionUTF8(value string, limit int) string { + if len(value) <= limit { + return value + } + + value = value[:limit] + for !utf8.ValidString(value) { + value = value[:len(value)-1] + } + + return value +} + +func encodeProviderVisibleExecutionValue(value any) ( + text string, + resultValue any, + partialValue any, + truncation *ExecutionTruncation, + err error, +) { + encoded, err := json.Marshal(value) + if err != nil { + return "", nil, nil, nil, oops.In("assistant").Code("execute_result_encode"). + Wrapf(err, "encode execute result") + } + + if len(encoded) <= MaxProviderVisibleExecutionResultSize { + return string(encoded), value, nil, nil, nil + } + + visible := encoded[:MaxProviderVisibleExecutionResultSize] + for !utf8.Valid(visible) { + visible = visible[:len(visible)-1] + } + + partial := string(visible) + truncation = &ExecutionTruncation{ + Field: "result_value", LimitBytes: MaxProviderVisibleExecutionResultSize, + OriginalBytes: len(encoded), VisibleBytes: len(visible), OmittedBytes: len(encoded) - len(visible), + } + + return partial, nil, partial, truncation, nil +} diff --git a/internal/assistant/execution_result_internal_test.go b/internal/assistant/execution_result_internal_test.go new file mode 100644 index 00000000..bacca545 --- /dev/null +++ b/internal/assistant/execution_result_internal_test.go @@ -0,0 +1,76 @@ +package assistant + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestExecutionResultKindsAreStable(t *testing.T) { + t.Parallel() + + tests := []struct { + kind ExecutionResultKind + want string + }{ + {ExecutionResultCompleted, "completed"}, + {ExecutionResultAccepted, "accepted"}, + {ExecutionResultFailed, "failed"}, + {ExecutionResultCanceled, "canceled"}, + {ExecutionResultTimedOut, "timed_out"}, + {ExecutionResultRejected, "rejected"}, + } + + for _, test := range tests { + t.Run(test.want, func(t *testing.T) { + t.Parallel() + assert.Equal(t, test.want, string(test.kind)) + }) + } +} + +func TestExecutionResultDetailsAddsIndependentDiscriminators(t *testing.T) { + t.Parallel() + + base := map[string]any{"result_value": 42} + details := executionResultDetails(base, MVMExecutionProfileTurn, ExecutionResultCompleted) + + assert.Equal(t, base, details) + assert.Equal(t, ExecutionResultCompleted, details[executionResultKindKey]) + assert.Equal(t, executionIdentityMVM, details[executionIdentityKey]) + assert.Equal(t, MVMExecutionProfileTurn, details[executionProfileKey]) + assert.Equal(t, 42, details[executeResultValueKey]) +} + +func TestExecutionResultEnvelopeOptionalFieldsAreAdditive(t *testing.T) { + t.Parallel() + + envelope := ExecutionResultEnvelope{ + PartialValue: map[string]any{"done": 1}, + ResultValue: nil, + ToolDetails: nil, + Truncation: nil, + Usage: map[string]any{"tokens": 3}, + RunID: "", + Stderr: "", + ResultKind: ExecutionResultFailed, + WorkflowTaskID: "", + Kind: "", + Name: "", + State: "", + Stdout: "", + Profile: MVMExecutionProfileDurable, + Execution: executionIdentityMVM, + Content: nil, + Artifacts: []map[string]any{{"id": "artifact-1"}}, + Warnings: []string{"bounded"}, + } + + encoded, _, _, _, err := encodeProviderVisibleExecutionValue(envelope) + require.NoError(t, err) + assert.Contains(t, encoded, `"artifacts"`) + assert.Contains(t, encoded, `"usage"`) + assert.Contains(t, encoded, `"warnings"`) + assert.Contains(t, encoded, `"partial_value"`) +} diff --git a/internal/assistant/workflow_tool.go b/internal/assistant/workflow_tool.go index cd95625c..e5e81104 100644 --- a/internal/assistant/workflow_tool.go +++ b/internal/assistant/workflow_tool.go @@ -65,31 +65,34 @@ func (executor *workflowToolExecutor) Definition() tool.Definition { func (executor *workflowToolExecutor) Execute(ctx context.Context, input tool.Arguments) (tool.Result, error) { if executor.submitter == nil { - return tool.TextResult("", nil), oops.In("assistant").Code("workflow_service_unavailable"). - Errorf("workflow service is unavailable") + return tool.TextResult("", executionResultDetails(nil, MVMExecutionProfileDurable, ExecutionResultRejected)), + oops.In("assistant").Code("workflow_service_unavailable"). + Errorf("workflow service is unavailable") } var args workflowToolInput if err := input.Decode(&args); err != nil { - return tool.TextResult("", nil), oops.In("assistant").Code("workflow_input").Wrapf(err, "decode workflow input") + return tool.TextResult("", executionResultDetails(nil, MVMExecutionProfileDurable, ExecutionResultRejected)), + oops.In("assistant").Code("workflow_input").Wrapf(err, "decode workflow input") } args.Name = strings.TrimSpace(args.Name) args.Source = strings.TrimSpace(args.Source) if args.Name == "" { - return tool.TextResult("", nil), oops.In("assistant").Code("workflow_name_required"). + return workflowOutcomeResult(ExecutionResultRejected), oops.In("assistant").Code("workflow_name_required"). Errorf("workflow name is required") } if args.Source == "" { - return tool.TextResult("", nil), oops.In("assistant").Code("workflow_source_required"). + return workflowOutcomeResult(ExecutionResultRejected), oops.In("assistant").Code("workflow_source_required"). Errorf("workflow source is required") } if !utf8.ValidString(args.Source) { - return tool.TextResult("", nil), oops.In("assistant").Code("workflow_source_invalid_utf8"). - Errorf("workflow source must be valid UTF-8") + return workflowOutcomeResult(ExecutionResultRejected), + oops.In("assistant").Code("workflow_source_invalid_utf8"). + Errorf("workflow source must be valid UTF-8") } arguments := args.Arguments @@ -99,7 +102,7 @@ func (executor *workflowToolExecutor) Execute(ctx context.Context, input tool.Ar argumentsJSON, err := json.Marshal(arguments) if err != nil { - return tool.TextResult("", nil), oops.In("assistant").Code("encode_workflow_arguments"). + return workflowOutcomeResult(ExecutionResultRejected), oops.In("assistant").Code("encode_workflow_arguments"). Wrapf(err, "encode workflow arguments") } @@ -108,7 +111,8 @@ func (executor *workflowToolExecutor) Execute(ctx context.Context, input tool.Ar ArgumentsJSON: string(argumentsJSON), OwnerSessionID: executor.ownerSessionID, }) if err != nil { - return tool.TextResult("", nil), oops.In("assistant").Code("submit_workflow").Wrapf(err, "submit workflow") + return workflowOutcomeResult(ExecutionResultFailed), oops.In("assistant").Code("submit_workflow"). + Wrapf(err, "submit workflow") } return tool.TextResult( @@ -117,15 +121,19 @@ func (executor *workflowToolExecutor) Execute(ctx context.Context, input tool.Ar ), nil } +func workflowOutcomeResult(kind ExecutionResultKind) tool.Result { + return tool.TextResult("", executionResultDetails(nil, MVMExecutionProfileDurable, kind)) +} + func workflowResultDetails(run *database.WorkflowRunEntity) map[string]any { if run == nil { return map[string]any{} } - return map[string]any{ + return executionResultDetails(map[string]any{ "run_id": run.Task.ID, workflowTaskIDKey: run.Task.ID, "kind": database.TaskKindWorkflow, executeNameKey: run.Name, "state": run.Task.State, - } + }, MVMExecutionProfileDurable, ExecutionResultAccepted) } var _ tool.Executor = (*workflowToolExecutor)(nil) diff --git a/internal/assistant/workflow_tool_internal_test.go b/internal/assistant/workflow_tool_internal_test.go index 72cc16ff..e802c37c 100644 --- a/internal/assistant/workflow_tool_internal_test.go +++ b/internal/assistant/workflow_tool_internal_test.go @@ -72,6 +72,9 @@ func TestWorkflowToolSubmitsModelAuthoredSource(t *testing.T) { assert.Equal(t, `Started workflow "review" with run ID run-1.`, result.Text()) assert.Equal(t, "run-1", result.Details["run_id"]) assert.Equal(t, "review", result.Details["name"]) + assert.Equal(t, ExecutionResultAccepted, result.Details[executionResultKindKey]) + assert.Equal(t, executionIdentityMVM, result.Details[executionIdentityKey]) + assert.Equal(t, MVMExecutionProfileDurable, result.Details[executionProfileKey]) require.NotNil(t, stub.request) assert.Equal(t, workflowTestSessionID, stub.request.OwnerSessionID) assert.Equal(t, "review", stub.request.Name) diff --git a/internal/database/testdata/workflow_compatibility_v10.sql b/internal/database/testdata/workflow_compatibility_v10.sql new file mode 100644 index 00000000..f4e375b6 --- /dev/null +++ b/internal/database/testdata/workflow_compatibility_v10.sql @@ -0,0 +1,36 @@ +PRAGMA foreign_keys = ON; + +INSERT INTO sessions (id, cwd, name, parent_session, created_at, updated_at) VALUES +('01900000-0000-7000-8000-000000000001', '/fixture', 'workflow owner', '', '2026-01-02T03:04:05Z', '2026-01-02T03:04:10Z'), -- NOSONAR: exported compatibility data intentionally repeats stable IDs and timestamps. +('01900000-0000-7000-8000-000000000002', '/fixture', 'first child', '01900000-0000-7000-8000-000000000001', '2026-01-02T03:04:06Z', '2026-01-02T03:04:10Z'), -- NOSONAR: exported compatibility data intentionally repeats stable IDs and timestamps. +('01900000-0000-7000-8000-000000000003', '/fixture', 'second child', '01900000-0000-7000-8000-000000000001', '2026-01-02T03:04:07Z', '2026-01-02T03:04:10Z'); + +INSERT INTO tasks +(id, kind, state, parent_task_id, owner_session_id, concurrency_key, result, error_code, error_message, + created_at, started_at, finished_at, updated_at, lease_owner, lease_expires_at) VALUES +('01900000-0000-7000-8000-000000000010', 'workflow', 'succeeded', NULL, '01900000-0000-7000-8000-000000000001', '', 'verified', '', '', '2026-01-02T03:04:05Z', '2026-01-02T03:04:06Z', '2026-01-02T03:04:10Z', '2026-01-02T03:04:10Z', NULL, NULL), -- NOSONAR: exported compatibility data intentionally repeats stable IDs and timestamps. +('01900000-0000-7000-8000-000000000011', 'agent', 'succeeded', '01900000-0000-7000-8000-000000000010', '01900000-0000-7000-8000-000000000001', '', 'found', '', '', '2026-01-02T03:04:06Z', '2026-01-02T03:04:07Z', '2026-01-02T03:04:08Z', '2026-01-02T03:04:08Z', NULL, NULL), -- NOSONAR: exported compatibility data intentionally repeats stable IDs and timestamps. +('01900000-0000-7000-8000-000000000012', 'agent', 'succeeded', '01900000-0000-7000-8000-000000000010', '01900000-0000-7000-8000-000000000001', '', 'verified', '', '', '2026-01-02T03:04:08Z', '2026-01-02T03:04:09Z', '2026-01-02T03:04:10Z', '2026-01-02T03:04:10Z', NULL, NULL); -- NOSONAR: exported compatibility data intentionally repeats stable IDs and timestamps. + +INSERT INTO workflow_runs (task_id, source, source_hash, source_version, arguments_json, name) VALUES +('01900000-0000-7000-8000-000000000010', 'import "librecode/workflow"; workflow.List()', 'sha256:fixture', 'v1', '{"scope":"terminal states"}', 'compatibility fixture'); + +INSERT INTO agent_tasks +(task_id, child_session_id, agent_name, prompt, model, provider, policy_json, usage_json, depth) VALUES +('01900000-0000-7000-8000-000000000011', '01900000-0000-7000-8000-000000000002', 'explore', 'find TaskState', 'fixture-model', 'fixture-provider', '{}', '{"input_tokens":10,"output_tokens":4}', 1), +('01900000-0000-7000-8000-000000000012', '01900000-0000-7000-8000-000000000003', 'review', 'verify terminal states', 'fixture-model', 'fixture-provider', '{}', '{"input_tokens":12,"output_tokens":3}', 1); + +INSERT INTO workflow_agent_tasks +(workflow_task_id, agent_task_id, sequence, node_key, invocation_index, created_at) VALUES +('01900000-0000-7000-8000-000000000010', '01900000-0000-7000-8000-000000000011', 1, 'inspect', 0, '2026-01-02T03:04:06Z'), +('01900000-0000-7000-8000-000000000010', '01900000-0000-7000-8000-000000000012', 2, 'inspect', 1, '2026-01-02T03:04:08Z'); + +INSERT INTO events (id, kind, payload_json, created_at) VALUES +('01900000-0000-7000-8000-000000000020', 'task_queued', '{}', '2026-01-02T03:04:05Z'), +('01900000-0000-7000-8000-000000000021', 'task_started', '{}', '2026-01-02T03:04:06Z'), +('01900000-0000-7000-8000-000000000022', 'task_succeeded', '{}', '2026-01-02T03:04:10Z'); + +INSERT INTO task_events (task_id, event_id, sequence) VALUES +('01900000-0000-7000-8000-000000000010', '01900000-0000-7000-8000-000000000020', 1), +('01900000-0000-7000-8000-000000000010', '01900000-0000-7000-8000-000000000021', 2), +('01900000-0000-7000-8000-000000000010', '01900000-0000-7000-8000-000000000022', 3); diff --git a/internal/database/workflow_compatibility_fixture_test.go b/internal/database/workflow_compatibility_fixture_test.go new file mode 100644 index 00000000..02ba04a5 --- /dev/null +++ b/internal/database/workflow_compatibility_fixture_test.go @@ -0,0 +1,70 @@ +package database_test + +import ( + _ "embed" // Embed the schema-v10 SQL fixture used by the compatibility test. + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/omarluq/librecode/internal/database" + "github.com/omarluq/librecode/internal/testutil" +) + +const ( + compatibilityOwnerID = "01900000-0000-7000-8000-000000000001" + compatibilityRunID = "01900000-0000-7000-8000-000000000010" + compatibilityNodeKey = "inspect" +) + +// workflowCompatibilityV10 is a data-only export created against schema version 10, +// before later unrelated migrations. It freezes workflow, event, and replay-link shapes. +// +//go:embed testdata/workflow_compatibility_v10.sql +var workflowCompatibilityV10 string + +func TestPersistedWorkflowCompatibilityFixture(t *testing.T) { + t.Parallel() + + connection := newMigratedThroughVersion(t, 10) + _, err := connection.ExecContext(t.Context(), workflowCompatibilityV10) + require.NoError(t, err) + require.NoError(t, database.Migrate(t.Context(), connection)) + + repository := testutil.WorkflowRepository(t, connection) + run, found, err := repository.Get(t.Context(), compatibilityRunID) + require.NoError(t, err) + require.True(t, found) + + assert.Equal(t, database.TaskKindWorkflow, run.Task.Kind) + assert.Equal(t, database.TaskSucceeded, run.Task.State) + assert.Equal(t, compatibilityOwnerID, run.Task.OwnerSessionID) + assert.Equal(t, "compatibility fixture", run.Name) + assert.Equal(t, "v1", run.SourceVersion) + assert.JSONEq(t, `{"scope":"terminal states"}`, run.ArgumentsJSON) + + links, err := repository.ListAgentTaskDetails(t.Context(), []string{compatibilityRunID}) + require.NoError(t, err) + require.Len(t, links, 2) + + assert.Equal(t, []string{compatibilityNodeKey, compatibilityNodeKey}, []string{ + links[0].Link.NodeKey, links[1].Link.NodeKey, + }) + assert.Equal(t, []int{0, 1}, []int{links[0].Link.InvocationIndex, links[1].Link.InvocationIndex}) + assert.Equal(t, []int64{1, 2}, []int64{links[0].Link.Sequence, links[1].Link.Sequence}) + assert.Equal(t, compatibilityOwnerID, links[0].AgentTask.Task.OwnerSessionID) + assert.Equal(t, compatibilityRunID, links[0].AgentTask.Task.ParentTaskID) + + replayed, found, err := repository.FindAgentTask(t.Context(), compatibilityRunID, compatibilityNodeKey, 1) + require.NoError(t, err) + require.True(t, found) + assert.Equal(t, links[1].AgentTask.Task.ID, replayed.AgentTaskID) + + events, err := repository.Tasks().ListEvents(t.Context(), compatibilityRunID, 0, 10) + require.NoError(t, err) + require.Len(t, events, 3) + assert.Equal(t, []int64{1, 2, 3}, []int64{events[0].Sequence, events[1].Sequence, events[2].Sequence}) + assert.Equal(t, []string{"task_queued", "task_started", "task_succeeded"}, []string{ + events[0].Event.Kind, events[1].Event.Kind, events[2].Event.Kind, + }) +} diff --git a/internal/guestapi/policy.go b/internal/guestapi/policy.go new file mode 100644 index 00000000..25d2106a --- /dev/null +++ b/internal/guestapi/policy.go @@ -0,0 +1,102 @@ +// Package guestapi defines the versioned public contract exposed to restricted +// MVM programs. It contains policy only; worker bindings remain responsible for +// enforcing the contract for a particular execution profile. +package guestapi + +// Version identifies a guest API independently of any persisted source format. +type Version string + +const ( + // Version1 is the currently deployed legacy guest API. + Version1 Version = "1" + // Version2 is the canonical unified package contract introduced by the + // unified execute migration. + Version2 Version = "2" + // CurrentVersion is the version new unified executions will request once + // profile-aware worker manifests are introduced. + CurrentVersion = Version2 +) + +// Profile identifies an execution guarantee profile for capability policy. +type Profile string + +const ( + // ProfileTurn identifies synchronous provider-turn execution. + ProfileTurn Profile = "turn" + // ProfileDurable identifies detached persisted workflow execution. + ProfileDurable Profile = "durable" +) + +// Canonical guest import paths. Artifact and state packages reserve names; they +// do not imply that those capabilities are implemented yet. +const ( + PackageTools = "librecode/tools" + PackageAgents = "librecode/agents" + PackageWorkflow = "librecode/workflow" + PackageArtifacts = "librecode/artifacts" + PackageState = "librecode/state" + + LegacyPackageTools = "tools" +) + +// CapabilityErrorCode is a stable machine-readable guest capability failure. +type CapabilityErrorCode string + +const ( + // ErrorUnavailable means a known capability is not exposed in the selected profile. + ErrorUnavailable CapabilityErrorCode = "guest_capability_unavailable" + // ErrorDenied means policy refused an otherwise supported capability. + ErrorDenied CapabilityErrorCode = "guest_capability_denied" + // ErrorUnsupported means the selected guest API/runtime does not implement the capability. + ErrorUnsupported CapabilityErrorCode = "guest_capability_unsupported" + // ErrorRecursive means a capability attempted to invoke the outer execute surface. + ErrorRecursive CapabilityErrorCode = "guest_capability_recursive" +) + +// Availability describes whether a canonical function is part of a profile's +// Version2 contract. Implemented is false for functions reserved for a later +// epic phase; callers must receive ErrorUnsupported until that phase ships. +type Availability struct { + Package string + Function string + Turn bool + Durable bool + Implemented bool +} + +// AvailabilityManifest returns the canonical function policy. +func AvailabilityManifest() []Availability { + return []Availability{ + {Package: PackageTools, Function: "Search", Turn: true, Durable: false, Implemented: false}, + {Package: PackageTools, Function: "Describe", Turn: true, Durable: false, Implemented: false}, + {Package: PackageTools, Function: "Call", Turn: true, Durable: false, Implemented: false}, + {Package: PackageAgents, Function: "Run", Turn: false, Durable: true, Implemented: false}, + {Package: PackageAgents, Function: "Spawn", Turn: false, Durable: true, Implemented: false}, + {Package: PackageAgents, Function: "Wait", Turn: false, Durable: true, Implemented: false}, + {Package: PackageAgents, Function: "List", Turn: false, Durable: true, Implemented: false}, + {Package: PackageAgents, Function: "Cancel", Turn: false, Durable: true, Implemented: false}, + {Package: PackageWorkflow, Function: "Parallel", Turn: true, Durable: true, Implemented: false}, + {Package: PackageWorkflow, Function: "Pipeline", Turn: true, Durable: true, Implemented: false}, + {Package: PackageWorkflow, Function: "Phase", Turn: true, Durable: true, Implemented: false}, + {Package: PackageWorkflow, Function: "Item", Turn: true, Durable: true, Implemented: false}, + {Package: PackageWorkflow, Function: "Event", Turn: true, Durable: true, Implemented: false}, + {Package: PackageWorkflow, Function: "Log", Turn: true, Durable: true, Implemented: false}, + {Package: PackageArtifacts, Function: "Put", Turn: true, Durable: true, Implemented: false}, + {Package: PackageArtifacts, Function: "Get", Turn: true, Durable: true, Implemented: false}, + {Package: PackageState, Function: "Get", Turn: false, Durable: true, Implemented: false}, + {Package: PackageState, Function: "Put", Turn: false, Durable: true, Implemented: false}, + } +} + +// Available reports whether a function belongs to the selected profile. It +// does not report implementation readiness, which is represented separately. +func (availability Availability) Available(profile Profile) bool { + switch profile { + case ProfileTurn: + return availability.Turn + case ProfileDurable: + return availability.Durable + default: + return false + } +} diff --git a/internal/guestapi/policy_test.go b/internal/guestapi/policy_test.go new file mode 100644 index 00000000..af182943 --- /dev/null +++ b/internal/guestapi/policy_test.go @@ -0,0 +1,99 @@ +package guestapi_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/omarluq/librecode/internal/guestapi" +) + +func TestVersionIsIndependentFromPersistedSourceVersion(t *testing.T) { + t.Parallel() + + assert.Equal(t, guestapi.CurrentVersion, guestapi.Version("2")) + assert.NotEqual(t, guestapi.CurrentVersion, guestapi.Version("v1")) +} + +func TestCanonicalPackageNames(t *testing.T) { + t.Parallel() + + assert.Equal(t, []string{ + "librecode/tools", + "librecode/agents", + "librecode/workflow", + "librecode/artifacts", + "librecode/state", + }, []string{ + guestapi.PackageTools, + guestapi.PackageAgents, + guestapi.PackageWorkflow, + guestapi.PackageArtifacts, + guestapi.PackageState, + }) + assert.Equal(t, "tools", guestapi.LegacyPackageTools) +} + +func TestAvailabilityManifest(t *testing.T) { + t.Parallel() + + tests := []struct { + packageName string + function string + turn bool + durable bool + implemented bool + }{ + {guestapi.PackageTools, "Call", true, false, false}, + {guestapi.PackageAgents, "Run", false, true, false}, + {guestapi.PackageAgents, "Spawn", false, true, false}, + {guestapi.PackageWorkflow, "Pipeline", true, true, false}, + {guestapi.PackageArtifacts, "Put", true, true, false}, + {guestapi.PackageState, "Get", false, true, false}, + } + + manifest := guestapi.AvailabilityManifest() + manifest[0].Function = "changed" + assert.NotEqual(t, "changed", guestapi.AvailabilityManifest()[0].Function) + + for _, test := range tests { + t.Run(test.packageName+"/"+test.function, func(t *testing.T) { + t.Parallel() + + var found *guestapi.Availability + + functionPolicy := guestapi.AvailabilityManifest() + for index := range functionPolicy { + candidate := &functionPolicy[index] + if candidate.Package == test.packageName && candidate.Function == test.function { + found = candidate + + break + } + } + + require.NotNil(t, found) + assert.Equal(t, test.turn, found.Available(guestapi.ProfileTurn)) + assert.Equal(t, test.durable, found.Available(guestapi.ProfileDurable)) + assert.False(t, found.Available(guestapi.Profile("unknown"))) + assert.Equal(t, test.implemented, found.Implemented) + }) + } +} + +func TestStableCapabilityErrorCodes(t *testing.T) { + t.Parallel() + + assert.Equal(t, []guestapi.CapabilityErrorCode{ + "guest_capability_unavailable", + "guest_capability_denied", + "guest_capability_unsupported", + "guest_capability_recursive", + }, []guestapi.CapabilityErrorCode{ + guestapi.ErrorUnavailable, + guestapi.ErrorDenied, + guestapi.ErrorUnsupported, + guestapi.ErrorRecursive, + }) +}