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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion config/dev.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -60,4 +60,5 @@ DAYTONA_API_URL=https://app.daytona.io/api
DAYTONA_TARGET=us
DAYTONA_SNAPSHOT=
DAYTONA_IMAGE=python:3.12-slim
DAYTONA_AUTO_PAUSE_MINUTES=15
# Optional; only sandbox classes that support pausing accept this setting.
DAYTONA_AUTO_PAUSE_MINUTES=
8 changes: 6 additions & 2 deletions docs/architecture/storage-context-and-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -265,8 +265,12 @@ For a large tool result:
2. when serialized output exceeds the documented 100,000-character threshold
(about 25,000 tokens), create a bounded model projection containing a
truncated preview, size, media type, and sandbox path;
3. create the documented public event projection;
4. record the sandbox path on the same durable tool step where applicable.
3. tell the Agent to inspect the exact saved file with bounded `bash` byte
slices; the line-oriented `read` tool is capped at 64 KiB inside every
sandbox provider and never downloads an arbitrarily large file into worker
memory;
4. create the documented public event projection;
5. record the sandbox path on the same durable tool step where applicable.

If the sandbox disappears unexpectedly, the Session workspace has been lost and
the runtime must surface that failure; it must not silently provision an empty
Expand Down
2 changes: 1 addition & 1 deletion docs/capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ and service test suites.
| Events and client actions | Limited | System context, messages, thinking, tool events, confirmation/custom/self-hosted result barriers, outcomes, retries, interrupts, and the budget-boundary `session.usage`/`budget_reached` idle sequence are implemented. File-backed message documents are limited to bounded UTF-8 text; File-sourced images and File documents in tool results are not supported. |
| Event streaming | Supported | PostgreSQL-authoritative Session and Thread streams with NATS wakeups, cursor repair, bounded backpressure, and opt-in ephemeral text previews. Streams do not replay history or interpret `Last-Event-ID`. |
| Model and context runtime | Limited | Durable provider-native transcripts, Catwalk-derived model-window profiles with a conservative fallback, provider-usage anchors plus post-anchor estimates, predictive request admission, extractive and oversized-tool-result compaction, one-shot working-turn overflow recovery, and immutable per-Thread turn-preparation checkpoints are implemented. Explicit custom-endpoint overrides, provider-exact counters, complete per-provider-request audit records, later-round projection checkpoints, equivalent Outcome/Advisor overflow recovery, and compaction quality and retention evidence remain open. |
| Sandbox tools | Limited | `bash`, `read`, `write`, `edit`, `glob`, and `grep`, plus provider-native Web Search/Fetch. Local is development-only; Docker and the Preview remote providers expose separately admitted resource capabilities. |
| Sandbox tools | Limited | `bash`, `read`, `write`, `edit`, `glob`, and `grep`, plus provider-native Web Search/Fetch. `read` supports 1-based inclusive line ranges and is capped at 64 KiB inside the sandbox; larger files and persisted tool outputs use bounded `bash` byte slices. Local is development-only; Docker and the Preview remote providers expose separately admitted resource capabilities. |
| MCP tools | Limited | Streamable HTTP discovery/execution, permissions, journaled calls, large-result materialization, and Vault bearer/OAuth authentication. Private-network connectivity, deprecated SSE, MCP resources, and prompts are not supported. |
| [Files](api/files.md) | Limited | Configured S3-compatible storage, crash-recoverable intents, reusable snapshotted UTF-8 outcome rubrics, bounded UTF-8 File documents snapshotted into `user.message`, downloadable Session Resource copies, and Docker/E2B/Cube/OpenSandbox/Daytona publication of regular files beneath `/mnt/session/outputs` before idle. Client uploads are intentionally not downloadable. E2B/Cube currently buffer each output archive in worker memory. File-sourced images/PDFs and distributed reconciliation remain open. |
| [Session Resources](api/session-resources.md) | Limited | Independent File copies, create-time Memory attachments, and create-time public HTTPS Git repository snapshots frozen to an exact commit. Runtime File attach/detach works. Git worktrees are writable on Docker/E2B/Cube/OpenSandbox/Daytona and restore offline from Mango storage. Private repository credentials, recursive submodules, LFS objects, repository Skill discovery, and runtime Git attach/detach remain open. Non-Docker Memory mounts are not supported. |
Expand Down
19 changes: 19 additions & 0 deletions docs/provenance.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,25 @@ release is never an automatic roadmap.
SDK are optional research evidence; raw HTTP and OpenAPI tests define Mango's
transport contract.

## Built-in Agent tools

- The public Agent Toolset shapes and executable cases in the pinned Anthropic
Go SDK informed Mango's line-oriented `read.view_range` behavior: ranges are
1-based and inclusive, and a non-positive end reads through EOF.
- Mango retains its existing `path`, `file_text`, `old_str`, and `new_str`
fields where they remain clear. The public SDK is design evidence, not a
field-for-field compatibility target or a runtime executor dependency.
- Mango does not advertise `bash.restart` because sandbox commands currently
execute independently and there is no persistent shell session to restart.
A future persistent-shell lifecycle must work through Mango's sandbox
abstraction before that capability can be exposed honestly.
- Mango caps each built-in `read` at 64 KiB inside the sandbox so untrusted
files cannot make worker memory scale without bound. Larger files and
persisted tool outputs use ordinary `bash` byte slicing (`dd`, `head`,
`tail`, or `sed`), following the established coding-agent split between a
line-oriented file viewer and a general shell rather than inventing a
Mango-specific character-pagination field.

## Outbound Webhooks

- The public [Claude Managed Agents Webhook guide](https://platform.claude.com/docs/en/managed-agents/webhooks),
Expand Down
90 changes: 88 additions & 2 deletions internal/agentruntime/tools/builtins.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,18 @@ package tools

import (
"context"
"encoding/json"
"fmt"
"math"
"regexp"
"sort"
"strings"

"github.com/yanpgwang/mango/internal/sandbox"
)

const MaxReadFileBytes = 64 << 10

// execBash runs a shell command inside the sandbox and returns its combined
// stdout+stderr as text. A non-zero exit code is reported as a tool error.
func execBash(ctx context.Context, sb sandbox.Sandbox, in map[string]any) Result {
Expand All @@ -33,11 +38,92 @@ func execRead(ctx context.Context, sb sandbox.Sandbox, in map[string]any) Result
if path == "" {
return textResult("read: path is required", true)
}
data, err := sb.ReadFile(ctx, path)
reader, ok := sb.(sandbox.BoundedFileReader)
if !ok {
return textResult("read: sandbox provider does not support bounded file reads", true)
}
data, truncated, err := reader.ReadFileBounded(ctx, path, MaxReadFileBytes)
if err != nil {
return textResult("read: "+err.Error(), true)
}
if truncated {
return textResult(fmt.Sprintf(
"read: file exceeds the %d-byte read limit; use bash with dd, head, tail, or sed to print a bounded slice",
MaxReadFileBytes,
), true)
}
startLine, endLine, ranged, err := parseViewRange(in["view_range"])
if err != nil {
return textResult("read: "+err.Error(), true)
}
return textResult(string(data), false)
if !ranged {
return textResult(string(data), false)
}
lines := strings.Split(string(data), "\n")
start := 0
if startLine > 1 {
start = startLine - 1
}
if start >= len(lines) {
return textResult("", false)
}
end := len(lines)
if endLine > 0 && endLine < end {
end = endLine
}
if end < start {
return textResult("", false)
}
return textResult(strings.Join(lines[start:end], "\n"), false)
}

func parseViewRange(raw any) (start, end int, present bool, err error) {
if raw == nil {
return 0, 0, false, nil
}
values, ok := raw.([]any)
if !ok || len(values) != 2 {
return 0, 0, true, fmt.Errorf("view_range must be [start_line, end_line]")
}
start, err = inputInteger(values[0])
if err != nil {
return 0, 0, true, fmt.Errorf("view_range start_line: %w", err)
}
end, err = inputInteger(values[1])
if err != nil {
return 0, 0, true, fmt.Errorf("view_range end_line: %w", err)
}
return start, end, true, nil
}

func inputInteger(raw any) (int, error) {
switch value := raw.(type) {
case int:
return value, nil
case int64:
converted := int(value)
if int64(converted) != value {
return 0, fmt.Errorf("must fit in an integer")
}
return converted, nil
case json.Number:
value64, err := value.Int64()
if err != nil {
return 0, fmt.Errorf("must be an integer")
}
return inputInteger(value64)
case float64:
if math.IsNaN(value) || math.IsInf(value, 0) || math.Trunc(value) != value {
return 0, fmt.Errorf("must be an integer")
}
converted := int(value)
if float64(converted) != value {
return 0, fmt.Errorf("must fit in an integer")
}
return converted, nil
default:
return 0, fmt.Errorf("must be an integer")
}
}

// execWrite writes file_text to a file in the sandbox, creating or truncating.
Expand Down
77 changes: 77 additions & 0 deletions internal/agentruntime/tools/builtins_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,67 @@ func TestBuiltins_WriteReadEditBash(t *testing.T) {
}
}

func TestBuiltins_ReadViewRange(t *testing.T) {
sb := newSB(t)
reg := Registry()
if r := reg["write"](context.Background(), sb, map[string]any{
"path": "lines.txt", "file_text": "line1\nline2\nline3",
}); r.IsError {
t.Fatalf("write: %+v", r)
}
tests := []struct {
name string
rangeIn any
want string
wantErr bool
}{
{name: "inclusive", rangeIn: []any{float64(2), float64(2)}, want: "line2"},
{name: "through eof", rangeIn: []any{float64(2), float64(0)}, want: "line2\nline3"},
{name: "inverted", rangeIn: []any{float64(3), float64(1)}, want: ""},
{name: "past eof", rangeIn: []any{float64(10), float64(12)}, want: ""},
{name: "wrong arity", rangeIn: []any{float64(2)}, wantErr: true},
{name: "fractional", rangeIn: []any{1.5, float64(2)}, wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := reg["read"](context.Background(), sb, map[string]any{
"path": "lines.txt", "view_range": tt.rangeIn,
})
if r.IsError != tt.wantErr {
t.Fatalf("read = %+v, want error %v", r, tt.wantErr)
}
if !tt.wantErr && resultText(t, r) != tt.want {
t.Fatalf("read text = %q, want %q", resultText(t, r), tt.want)
}
})
}
}

func TestBuiltins_ReadRejectsOversizedFileWithoutReturningItsContents(t *testing.T) {
sb := newSB(t)
full := strings.Repeat("secret", MaxReadFileBytes/6+1)
if err := sb.WriteFile(context.Background(), "large.txt", []byte(full)); err != nil {
t.Fatal(err)
}
r := Registry()["read"](context.Background(), sb, map[string]any{
"path": "large.txt", "view_range": []any{float64(1), float64(1)},
})
if !r.IsError || !contains(r, "use bash") || contains(r, "secret") {
t.Fatalf("oversized read = %#v", r)
}
}

func TestBuiltinSchemasOnlyAdvertiseImplementedSemantics(t *testing.T) {
bashProperties := Schema("bash")["properties"].(map[string]any)
if _, advertised := bashProperties["restart"]; advertised {
t.Fatal("bash schema advertises a persistent-shell restart that the executor does not implement")
}
readProperties := Schema("read")["properties"].(map[string]any)
if _, advertised := readProperties["view_range"]; !advertised {
t.Fatal("read schema omitted the implemented view_range")
}
}

func TestBuiltins_EditMissingStringIsError(t *testing.T) {
sb := newSB(t)
Registry()["write"](context.Background(), sb, map[string]any{"path": "y.txt", "file_text": "abc"})
Expand Down Expand Up @@ -152,3 +213,19 @@ func contains(r Result, s string) bool {
}
return false
}

func resultText(t *testing.T, r Result) string {
t.Helper()
if len(r.Content) != 1 {
t.Fatalf("result content = %#v", r.Content)
}
block, ok := r.Content[0].(map[string]any)
if !ok {
t.Fatalf("result block = %#v", r.Content[0])
}
text, ok := block["text"].(string)
if !ok {
t.Fatalf("result text = %#v", block["text"])
}
return text
}
9 changes: 3 additions & 6 deletions internal/agentruntime/tools/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,8 @@ func Names() []string {
}

// Schema returns the model-facing JSON input_schema for the named tool, or nil
// if the tool is unknown. Shapes follow Anthropic's public tool conventions.
// if the tool is unknown. Shapes adapt useful public tool conventions to the
// semantics Mango actually implements.
//
// web_fetch/web_search are declared to the model through their native routing
// path, but still need legal local schema objects for shared configuration and
Expand All @@ -88,10 +89,6 @@ func Schema(name string) map[string]any {
"type": "string",
"description": "The shell command to run.",
},
"restart": map[string]any{
"type": "boolean",
"description": "Restart the shell session before running.",
},
},
"required": []any{"command"},
}
Expand All @@ -105,7 +102,7 @@ func Schema(name string) map[string]any {
},
"view_range": map[string]any{
"type": "array",
"description": "Optional [start, end] 1-based line range to view.",
"description": "Optional [start, end] 1-based inclusive line range to view; a non-positive end reads through EOF.",
"items": map[string]any{"type": "integer"},
"minItems": 2,
"maxItems": 2,
Expand Down
6 changes: 5 additions & 1 deletion internal/agentruntime/tools/result_storage.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,16 @@ func MaterializeLargeResult(
message := fmt.Sprintf(
"<persisted-output>\n"+
"Tool output exceeded %d characters. The full output was saved to %s (%d characters).\n"+
"Use the read tool with view_range to inspect it in chunks.\n\n"+
"Use bash to inspect it in byte chunks. For example:\n"+
"dd if=%s bs=%d skip=0 count=1 2>/dev/null\n"+
"Increase skip by 1 to continue without emitting another oversized result.\n\n"+
"Preview:\n%s\n"+
"</persisted-output>",
MaxInlineResultChars,
resultPath,
characters,
resultPath,
MaxReadFileBytes,
preview,
)
return textResult(message, result.IsError), nil
Expand Down
43 changes: 43 additions & 0 deletions internal/agentruntime/tools/result_storage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,46 @@ func TestMaterializeLargeResult_LeavesThresholdInline(t *testing.T) {
t.Fatal("threshold-sized result should not be written")
}
}

func TestMaterializeLargeResult_CanBeInspectedInBoundedBashChunks(t *testing.T) {
sb := newSB(t)
full := strings.Repeat("0123456789", MaxInlineResultChars/10+1)
materialized, err := MaterializeLargeResult(
context.Background(),
sb,
"sevt_chunked",
textResult(full, false),
)
if err != nil {
t.Fatal(err)
}
message := resultText(t, materialized)
if !strings.Contains(message, "dd if=tool-results/sevt_chunked.txt") ||
!strings.Contains(message, "bs=65536 skip=0 count=1") {
t.Fatalf("materialized guidance = %q", message)
}

readResult := Registry()["read"](context.Background(), sb, map[string]any{
"path": "tool-results/sevt_chunked.txt",
"view_range": []any{float64(1), float64(1)},
})
if !readResult.IsError || !strings.Contains(resultText(t, readResult), "use bash") {
t.Fatalf("oversized line read = %#v", readResult)
}
rematerialized, err := MaterializeLargeResult(
context.Background(), sb, "sevt_read_retry", readResult,
)
if err != nil {
t.Fatal(err)
}
if resultText(t, rematerialized) != resultText(t, readResult) {
t.Fatalf("read error was materialized again: %#v", rematerialized)
}

chunk := Registry()["bash"](context.Background(), sb, map[string]any{
"command": "dd if=tool-results/sevt_chunked.txt bs=65536 skip=1 count=1 2>/dev/null",
})
if chunk.IsError || resultText(t, chunk) != full[65536:] {
t.Fatalf("bounded bash chunk = %#v", chunk)
}
}
Loading