diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 7ec0c13..e88423c 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -112,12 +112,27 @@ jobs: version: ${{ needs.detect-release.outputs.version }} secrets: inherit + publish-rust: + name: Publish Rust SDK + needs: detect-release + if: needs.detect-release.outputs.should_release == 'true' + uses: ./.github/workflows/reusable-sdk-release.yml + permissions: + contents: read + with: + sdk: rust + working-directory: runagent-rust/runagent + tag: ${{ needs.detect-release.outputs.latest_tag }} + version: ${{ needs.detect-release.outputs.version }} + secrets: inherit + create-release: name: Create GitHub Release needs: - detect-release - publish-python - publish-typescript + - publish-rust if: needs.detect-release.outputs.should_release == 'true' runs-on: ubuntu-latest permissions: diff --git a/.github/workflows/reusable-sdk-release.yml b/.github/workflows/reusable-sdk-release.yml index 0c9b1b8..7d87bbd 100644 --- a/.github/workflows/reusable-sdk-release.yml +++ b/.github/workflows/reusable-sdk-release.yml @@ -29,11 +29,18 @@ on: required: false type: string default: "20" + rust: + description: Enable Rust publish path (sdk == rust) + required: false + type: string + default: "" secrets: PYPI_API_TOKEN: required: false NPM_TOKEN: required: false + CARGO_REGISTRY_TOKEN: + required: false jobs: python: @@ -56,3 +63,12 @@ jobs: node-version: ${{ inputs.node-version }} secrets: inherit + rust: + if: inputs.sdk == 'rust' && inputs.version != '' + uses: ./.github/workflows/rust-release.yml + with: + working-directory: ${{ inputs.working-directory }} + tag: ${{ inputs.tag }} + version: ${{ inputs.version }} + secrets: inherit + diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml new file mode 100644 index 0000000..e6723a5 --- /dev/null +++ b/.github/workflows/rust-release.yml @@ -0,0 +1,102 @@ +name: Rust SDK Release + +on: + workflow_call: + inputs: + working-directory: + description: Path where cargo commands should run + required: true + type: string + tag: + description: Git tag for this release (e.g., v0.1.0) + required: true + type: string + version: + description: Crate version without the leading v (e.g., 0.1.0) + required: true + type: string + +jobs: + publish: + name: Publish Rust SDK + runs-on: ubuntu-latest + if: inputs.version != '' + permissions: + contents: read + id-token: write + defaults: + run: + shell: bash + working-directory: ${{ inputs.working-directory }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Show release context + run: | + echo "Tag: ${{ inputs.tag }}" + echo "Version: ${{ inputs.version }}" + echo "Working directory: ${{ inputs.working-directory }}" + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + + - name: Authenticate to crates.io (trusted publishing) + id: auth + uses: rust-lang/crates-io-auth-action@v1 + + - name: Verify crate version matches tag + id: verify-rust-version + run: | + TAG_VERSION="${{ inputs.version }}" + PACKAGE_VERSION=$(grep -E '^version\s*=' Cargo.toml | head -1 | sed -E 's/^version\s*=\s*"([^"]+)".*/\1/') + echo "Detected crate version: $PACKAGE_VERSION" + if [ "$PACKAGE_VERSION" != "$TAG_VERSION" ]; then + echo "❌ Version mismatch! Tag: $TAG_VERSION, Cargo.toml: $PACKAGE_VERSION" + exit 1 + fi + echo "✅ Rust crate version matches tag" + + - name: Get crate name + id: get-crate + run: | + NAME=$(grep -E '^name\s*=' Cargo.toml | head -1 | sed -E 's/^name\s*=\s*"([^"]+)".*/\1/') + echo "crate_name=$NAME" >> "$GITHUB_OUTPUT" + echo "Crate name: $NAME" + + - name: Check if crate version already exists on crates.io + id: check-crates + run: | + VERSION="${{ inputs.version }}" + NAME="${{ steps.get-crate.outputs.crate_name }}" + HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "https://crates.io/api/v1/crates/$NAME/$VERSION") + if [ "$HTTP_CODE" = "200" ]; then + echo "⚠️ $NAME v$VERSION already exists on crates.io" + echo "should_publish=false" >> "$GITHUB_OUTPUT" + else + echo "✅ $NAME v$VERSION not found on crates.io" + echo "should_publish=true" >> "$GITHUB_OUTPUT" + fi + + - name: Lint and test (clippy + tests) + run: | + cargo fmt -- --check + cargo clippy --all-targets --all-features -- -D warnings + cargo test --all-features + + - name: Package dry run + run: cargo publish --dry-run + + - name: Publish crate to crates.io + if: steps.check-crates.outputs.should_publish == 'true' + run: cargo publish + env: + CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }} + + - name: Skip publish (already exists) + if: steps.check-crates.outputs.should_publish != 'true' + run: echo "Skipping crates.io publish because version already exists." + + diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cf70c2..8f01cf6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,14 +1,10 @@ # Changelog All notable changes to this project's latest version. -## [0.1.32] - 2025-11-14 - -### Features - -- Added /architecture endpoint support to all server & sdk +## [0.1.36] - 2025-11-18 ### Miscellaneous Tasks -- Bump version to v0.1.32 +- Bump version to v0.1.36 diff --git a/pyproject.toml b/pyproject.toml index b3a757d..b076cc2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "runagent" -version = "0.1.33" +version = "0.1.36" description = "A command-line tool and SDK for deploying, managing, and interacting with AI agents" readme = "README.md" requires-python = ">=3.9" @@ -103,7 +103,7 @@ line_length = 88 skip = ["docs"] [tool.mypy] -python_version = "0.1.33" +python_version = "0.1.36" warn_return_any = true warn_unused_configs = true disallow_untyped_defs = true @@ -159,7 +159,7 @@ fail_under = 80 [tool.ruff] line-length = 88 -target-version = "0.1.33" +target-version = "0.1.36" select = [ "E", # pycodestyle errors "W", # pycodestyle warnings diff --git a/runagent-go/PUBLISH.md b/runagent-go/PUBLISH.md new file mode 100644 index 0000000..912dee0 --- /dev/null +++ b/runagent-go/PUBLISH.md @@ -0,0 +1,78 @@ +## Publishing `runagent-go` + +The Go SDK is distributed through this monorepo using Go modules. Releasing a new version requires tagging the repository so `go get github.com/runagent-dev/runagent/runagent-go/runagent@vX.Y.Z` resolves to the new code. + +--- + +### 1. Prerequisites + +- Go 1.21+ installed locally. +- Write access to the repo and permission to push tags. +- Clean working tree (`git status` should be clean or contain only staged release commits). + +--- + +### 2. Preflight Checklist + +1. **Bump the SDK version** + - Update `runagent/runagent-go/runagent/version.go`. + - Follow semver (increment patch for fixes, minor for new features, major for breaking changes). +2. **Changelog / release notes** + - Update the main repo changelog or docs to record the release. +3. **Verify dependencies** + - Run `go mod tidy` from `runagent-go/`. + - Ensure `go.mod`/`go.sum` contain only the needed deps. + +--- + +### 3. Build & Test + +```bash +# From runagent-go/ +go test ./runagent/... +golangci-lint run ./runagent/... # optional but recommended +``` + +For extra assurance, run the example binaries: + +```bash +go run ./examples/basic.go +go run ./examples/streaming.go +``` + +--- + +### 4. Commit & Tag + +```bash +git add runagent-go +git commit -m "chore(go): release v0.1.34" + +# Tag with the `sdk-go-` prefix so automation can detect it +git tag sdk-go-v0.1.34 +git push origin main +git push origin sdk-go-v0.1.34 +``` + +> If releasing from a feature branch, merge it first (or push the tag from the release branch) so `main` reflects the published state. + +--- + +### 5. Post-Publish + +- Announce the release internally and update documentation links (docs site, README tables, etc.). +- Monitor `go proxy` and `pkg.go.dev` (usually available within minutes after pushing the tag). +- Verify `go list -m github.com/runagent-dev/runagent/runagent-go/runagent@latest` resolves to the new version. + +--- + +### Troubleshooting + +- **`module lookup disabled`**: ensure the tag exists on the default branch and follows the `vX.Y.Z` semver format. +- **Stale code from `go get`**: run `GONOSUMDB=* GOPROXY=direct go list -m github.com/...@latest` to force a refresh. +- **Forgot to bump version**: retagging is not supported; create a new patch release (e.g., `v0.1.35`) with the correct version. + +--- + +You’re done! 🚀 + diff --git a/runagent-go/README.md b/runagent-go/README.md new file mode 100644 index 0000000..0db82b8 --- /dev/null +++ b/runagent-go/README.md @@ -0,0 +1,202 @@ +## RunAgent Go SDK + +The Go SDK mirrors the Python CLI client so Go services can trigger hosted or local RunAgent deployments. It wraps the `/api/v1/agents/{agent_id}/run` and `/run-stream` endpoints, handles auth/discovery, and translates responses into Go-friendly types. + +--- + +### Feature Overview + +- Native Go arguments: + - Positional: `Arg(...)`, `Args(...)` + - Keyword: `Kw(k, v)`, `Kws(map[string]any)` + - Structs become kwargs via `json` tags + - Single primitives become a single positional arg +- Streaming and non-streaming guardrails: + - `Run` rejects `*_stream` tags with a helpful error + - `RunStream` rejects non-stream tags with a helpful error +- Local vs Remote: + - Local DB discovery from `~/.runagent/runagent_local.db` (override with `Host`/`Port`) + - Remote uses `RUNAGENT_BASE_URL` (default `https://backend.run-agent.ai`) and Bearer token +- Authentication: + - `Authorization: Bearer RUNAGENT_API_KEY` automatically for remote calls + - WS token fallback `?token=...` for streams +- Error taxonomy: + - `AUTHENTICATION_ERROR`, `CONNECTION_ERROR`, `VALIDATION_ERROR`, `SERVER_ERROR`, `UNKNOWN_ERROR` + - Execution errors include `Code`, `Suggestion`, `Details` when provided by backend +- Architecture: + - `GetArchitecture(ctx)` normalizes envelope and legacy formats and enforces `ARCHITECTURE_MISSING` when needed +- Config precedence: + - Explicit `Config` fields → environment → defaults +- Extra params: + - `Config.ExtraParams` stored and retrievable via `client.ExtraParams()` + +--- + +### Installation + +```bash +go get github.com/runagent-dev/runagent/runagent-go/runagent +``` + +Requires Go 1.21+. + +--- + +### Configuration Precedence + +1. Explicit `runagent.Config` fields +2. Environment variables + - `RUNAGENT_API_KEY` + - `RUNAGENT_BASE_URL` (defaults to `https://backend.run-agent.ai`) + - `RUNAGENT_LOCAL`, `RUNAGENT_HOST`, `RUNAGENT_PORT`, `RUNAGENT_TIMEOUT` +3. Library defaults (e.g., local DB discovery, 300 s timeout) + +When `Local` is true (or `RUNAGENT_LOCAL=true`), the SDK reads `~/.runagent/runagent_local.db` to discover the host/port unless they’re provided directly. + +--- + +### Local vs Remote: Host/Port Optionality + +- Remote (cloud or self-hosted base URL): + - Do not set `Host`/`Port`. Provide `APIKey` (or set `RUNAGENT_API_KEY`), and optionally `BaseURL`. + - Example: + ```go + client, _ := runagent.NewRunAgentClient(runagent.Config{ + AgentID: "id", + EntrypointTag: "minimal", + APIKey: os.Getenv("RUNAGENT_API_KEY"), + // BaseURL optional; defaults to https://backend.run-agent.ai + }) + ``` +- Local: + - `Host`/`Port` are optional. If either is missing, the SDK discovers the value(s) from `~/.runagent/runagent_local.db` for the given `AgentID`. + - If discovery fails (agent not registered), you’ll get a clear `VALIDATION_ERROR` suggesting to pass `Host`/`Port` or register the agent locally. + - Examples: + ```go + // Rely fully on DB discovery (no host/port) + client, _ := runagent.NewRunAgentClient(runagent.Config{ + AgentID: "local-id", + EntrypointTag: "generic", + Local: runagent.Bool(true), + }) + // Provide only Host, let Port be discovered + client, _ = runagent.NewRunAgentClient(runagent.Config{ + AgentID: "local-id", + EntrypointTag: "generic", + Local: runagent.Bool(true), + Host: "127.0.0.1", + }) + ``` + +--- + +### Quickstart (Remote) + +```go +package main + +import ( + "context" + "fmt" + "time" + "os" + + "github.com/runagent-dev/runagent/runagent-go/runagent" +) + +func main() { + client, err := runagent.NewRunAgentClient(runagent.Config{ + AgentID: "YOUR_AGENT_ID", + EntrypointTag: "minimal", + APIKey: os.Getenv("RUNAGENT_API_KEY"), + }) + if err != nil { + panic(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + result, err := client.Run(ctx, runagent.Kw("message", "Summarize Q4 retention metrics")) + if err != nil { + panic(err) + } + fmt.Printf("Response: %#v\n", result) +} +``` + +--- + +### Quickstart (Local) + +```go +client, err := runagent.NewRunAgentClient(runagent.Config{ + AgentID: "local-agent-id", + EntrypointTag: "generic", + Local: runagent.Bool(true), + Host: "127.0.0.1", // optional: falls back to DB entry + Port: 8450, +}) +``` + +If `Host`/`Port` are omitted, the SDK looks up the agent in `~/.runagent/runagent_local.db`. Missing entries yield a helpful `VALIDATION_ERROR`. + +--- + +### Streaming Responses + +```go +stream, err := client.RunStream(ctx, runagent.Kw("prompt", "Stream a haiku about Go")) +if err != nil { + log.Fatal(err) +} +defer stream.Close() + +for { + // Panic with a friendly message on errors (quickstart ergonomics) + chunk := stream.NextOrPanic(ctx) + fmt.Print(chunk) +} +``` + +- Local streams connect to `ws://{host}:{port}/api/v1/agents/{id}/run-stream`. +- Remote streams upgrade to `wss://backend.run-agent.ai/api/v1/...` and append `?token=RUNAGENT_API_KEY`. + +--- + +### Extra Params & Metadata + +`Config.ExtraParams` accepts arbitrary metadata; call `client.ExtraParams()` to retrieve a copy. Reserved for future features (tracing, tags) without breaking the API. + +--- + +### Error Handling + +All SDK errors satisfy `error` and expose a concrete `*runagent.RunAgentError`: + +| Type | Meaning | Typical Fix | +| --- | --- | --- | +| `AUTHENTICATION_ERROR` | API key missing/invalid | Set `RUNAGENT_API_KEY` or `Config.APIKey` | +| `CONNECTION_ERROR` | Network/DNS/TLS issues | Verify network, agent uptime | +| `VALIDATION_ERROR` | Bad config or missing agent | Check `agent_id`, entrypoint, local DB | +| `SERVER_ERROR` | Upstream failure (5xx) | Retry or inspect agent logs | + +Remote responses that return a structured `error` block become `RunAgentExecutionError` with `Code`, `Suggestion`, and `Details` copied directly. + +Use `errors.As(err, &runErr)` to inspect fields. + +--- + +### Testing & Troubleshooting + +- `go test ./runagent/...` exercises the SDK build. +- Enable debug logging in your application to capture request IDs. +- For local issues, run `runagent cli agents list` to confirm the SQLite database contains the agent and the host/port match. +- For remote failures, confirm the agent is deployed and the entrypoint tag is enabled in the RunAgent Cloud dashboard. + +--- + +### Publishing + +See `PUBLISH.md` in this directory for release instructions (version bumps, tagging, and module proxy propagation). + diff --git a/runagent-go/examples/basic.go b/runagent-go/examples/basic.go index 5a97c2b..1ca5b31 100644 --- a/runagent-go/examples/basic.go +++ b/runagent-go/examples/basic.go @@ -12,27 +12,24 @@ import ( func main() { fmt.Println("=== Example 1: Non-Streaming ===") - config := runagent.Config{ + client, err := runagent.NewRunAgentClient(runagent.Config{ AgentID: "841debad-7433-46ae-a0ec-0540d0df7314", EntrypointTag: "minimal", Host: "localhost", Port: 8450, - Local: true, + Local: runagent.Bool(true), + }) + if err != nil { + log.Fatalf("failed to create client: %v", err) } - client := runagent.NewRunAgentClient(config) - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() - if err := client.Initialize(ctx); err != nil { - log.Fatalf("Failed to initialize: %v", err) - } - - solutionResult, err := client.Run(ctx, map[string]interface{}{ - "role": "user", - "message": "Analyze the benefits of remote work for software teams", - }) + solutionResult, err := client.Run(ctx, + runagent.Kw("role", "user"), + runagent.Kw("message", "Analyze the benefits of remote work for software teams"), + ) if err != nil { log.Fatalf("Failed to run agent: %v", err) } diff --git a/runagent-go/examples/streaming.go b/runagent-go/examples/streaming.go index 0ce4ba4..47e58dd 100644 --- a/runagent-go/examples/streaming.go +++ b/runagent-go/examples/streaming.go @@ -12,33 +12,27 @@ import ( func main() { fmt.Println("=== Streaming Agent Example ===") - // Create client - client := runagent.NewRunAgentClient(runagent.Config{ + client, err := runagent.NewRunAgentClient(runagent.Config{ AgentID: "841debad-7433-46ae-a0ec-0540d0df7314", EntrypointTag: "minimal_stream", Host: "localhost", Port: 8450, - Local: true, + Local: runagent.Bool(true), }) + if err != nil { + log.Fatalf("failed to create client: %v", err) + } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() - // Initialize - if err := client.Initialize(ctx); err != nil { - log.Fatalf("Failed to initialize: %v", err) - } - - // Start streaming - result, err := client.Run(ctx, map[string]interface{}{ - "role": "user", - "message": "Write a detailed analysis of remote work benefits for software development teams", - }) + stream, err := client.RunStream(ctx, + runagent.Kw("role", "user"), + runagent.Kw("message", "Write a detailed analysis of remote work benefits for software development teams"), + ) if err != nil { log.Fatalf("Failed to start streaming: %v", err) } - - stream := result.(*runagent.StreamIterator) defer stream.Close() fmt.Println("📡 Streaming response:") diff --git a/runagent-go/go.mod b/runagent-go/go.mod index 750bc43..dd9693c 100644 --- a/runagent-go/go.mod +++ b/runagent-go/go.mod @@ -2,5 +2,8 @@ module github.com/runagent-dev/runagent/runagent-go go 1.23.4 -require github.com/gorilla/websocket v1.5.3 - +require ( + github.com/gorilla/mux v1.8.1 + github.com/gorilla/websocket v1.5.3 + github.com/mattn/go-sqlite3 v1.14.28 +) diff --git a/runagent-go/runagent/client.go b/runagent-go/runagent/client.go new file mode 100644 index 0000000..f7dc99b --- /dev/null +++ b/runagent-go/runagent/client.go @@ -0,0 +1,779 @@ +package runagent + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "reflect" + "net/url" + "os" + "strconv" + "strings" + "time" + + "github.com/gorilla/websocket" + + "github.com/runagent-dev/runagent/runagent-go/runagent/pkg/constants" + "github.com/runagent-dev/runagent/runagent-go/runagent/pkg/db" +) + +// RunAgentClient is the main entry point for invoking RunAgent deployments. +type RunAgentClient struct { + agentID string + entrypointTag string + local bool + baseRESTURL string + baseSocketURL string + apiKey string + timeoutSecs int + asyncDefault bool + extraParams map[string]interface{} + httpClient *http.Client +} + +// NewRunAgentClient creates a new client instance using the provided config. +func NewRunAgentClient(cfg Config) (*RunAgentClient, error) { + if strings.TrimSpace(cfg.AgentID) == "" { + return nil, newError(ErrorTypeValidation, "agent_id is required") + } + if strings.TrimSpace(cfg.EntrypointTag) == "" { + return nil, newError(ErrorTypeValidation, "entrypoint_tag is required") + } + + env := loadEnvConfig() + + local := resolveBool(cfg.Local, env.local, false) + asyncDefault := resolveBool(cfg.AsyncExecution, nil, false) + + timeout := cfg.TimeoutSeconds + if timeout <= 0 { + timeout = env.timeoutSeconds + } + if timeout <= 0 { + timeout = constants.DefaultTimeoutSeconds + } + + apiKey := firstNonEmpty(cfg.APIKey, env.apiKey) + baseURL := firstNonEmpty(cfg.BaseURL, env.baseURL, constants.DefaultBaseURL) + + var restBase, socketBase string + var host string + var port int + if local { + host = firstNonEmpty(cfg.Host, env.host) + port = firstNonZero(cfg.Port, env.port) + + if host == "" || port == 0 { + discoveredHost, discoveredPort, err := discoverLocalAgent(cfg.AgentID) + if err != nil { + return nil, err + } + if host == "" { + host = discoveredHost + } + if port == 0 { + port = discoveredPort + } + } + + if host == "" || port == 0 { + return nil, newError( + ErrorTypeValidation, + "unable to resolve local host/port", + withSuggestion("Pass Config.Host/Config.Port or ensure the agent is registered locally"), + ) + } + + restBase = fmt.Sprintf("http://%s:%d%s", host, port, constants.DefaultAPIPrefix) + socketBase = fmt.Sprintf("ws://%s:%d%s", host, port, constants.DefaultAPIPrefix) + } else { + var err error + restBase, socketBase, err = normalizeRemoteBases(baseURL) + if err != nil { + return nil, err + } + } + + httpClient := cfg.HTTPClient + if httpClient == nil { + httpClient = &http.Client{ + Timeout: time.Duration(timeout) * time.Second, + } + } + + extra := cfg.ExtraParams + if extra == nil { + extra = map[string]interface{}{} + } + + return &RunAgentClient{ + agentID: cfg.AgentID, + entrypointTag: cfg.EntrypointTag, + local: local, + baseRESTURL: restBase, + baseSocketURL: socketBase, + apiKey: apiKey, + timeoutSecs: timeout, + asyncDefault: asyncDefault, + extraParams: extra, + httpClient: httpClient, + }, nil +} + +// Run invokes the agent using native Go-shaped arguments. +// Examples: +// - positional: Run(ctx, Arg("q"), Arg(4)) +// - keyword: Run(ctx, Kws(map[string]any{"m":3})) +// - mixed: Run(ctx, Args("q",4), Kw("m",3)) +// - struct: Run(ctx, MyStruct{...}) -> kwargs via json tags +// - single: Run(ctx, "hello") -> ["hello"], {} +func (c *RunAgentClient) Run(ctx context.Context, values ...any) (interface{}, error) { + // Guardrail: non-stream only + if c.entrypointTag == "generic_stream" || c.entrypointTag == "stream" || strings.HasSuffix(strings.ToLower(c.entrypointTag), "_stream") { + return nil, newError( + ErrorTypeValidation, + "stream entrypoint must be invoked with RunStream", + withCode("STREAM_ENTRYPOINT"), + withSuggestion("Use client.RunStream(...) for *_stream tags"), + ) + } + + input, err := coerceToRunInput(values...) + if err != nil { + return nil, err + } + payload := input.toAPIPayload(c.entrypointTag, c.timeoutSecs, c.asyncDefault) + + body, err := json.Marshal(payload) + if err != nil { + return nil, newError(ErrorTypeValidation, "failed to serialize request", withCause(err)) + } + + endpoint := fmt.Sprintf("%s/agents/%s/run", c.baseRESTURL, c.agentID) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return nil, newError(ErrorTypeUnknown, "failed to create request", withCause(err)) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", userAgent()) + if !c.local { + if c.apiKey == "" { + return nil, newError( + ErrorTypeAuthentication, + "api_key is required for remote runs", + withSuggestion("Set RUNAGENT_API_KEY or pass Config.APIKey"), + ) + } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.apiKey)) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, newError( + ErrorTypeConnection, + "failed to reach RunAgent service", + withCause(err), + withSuggestion("Check your network connection or agent status"), + ) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, newError(ErrorTypeUnknown, "failed to read response body", withCause(err)) + } + + if resp.StatusCode != http.StatusOK { + return nil, translateHTTPError(resp.StatusCode, respBody) + } + + return parseRunResponse(resp.StatusCode, respBody) +} + +// RunNative invokes the agent using native Go-shaped arguments without requiring RunInput. +// Usage: +// - positional: RunNative(ctx, Arg("q"), Arg(4)) +// - keyword: RunNative(ctx, Kws(map[string]any{"m": 3, "n": 4})) +// - mixed: RunNative(ctx, Args("q", 4), Kw("m", 3), Kw("n", 4)) +// - struct: RunNative(ctx, MyStruct{...}) -> kwargs via json tags +// - single: RunNative(ctx, "hello") -> ["hello"], {} +func (c *RunAgentClient) RunNative(ctx context.Context, values ...any) (interface{}, error) { + input, err := coerceToRunInput(values...) + if err != nil { + return nil, err + } + return c.Run(ctx, input) +} + +// RunStream starts a streaming execution via WebSocket using native arguments. +func (c *RunAgentClient) RunStream(ctx context.Context, values ...any) (*StreamIterator, error) { + // Guardrail: stream only + if !(c.entrypointTag == "generic_stream" || c.entrypointTag == "stream" || strings.HasSuffix(strings.ToLower(c.entrypointTag), "_stream")) { + return nil, newError( + ErrorTypeValidation, + "non-stream entrypoint must be invoked with Run", + withCode("NON_STREAM_ENTRYPOINT"), + withSuggestion("Use client.Run(...) for non-stream tags"), + ) + } + + input, err := coerceToRunInput(values...) + if err != nil { + return nil, err + } + + // Optional final StreamOptions can be passed via Kw("__timeout_seconds__", x) + // but we keep defaults; consider functional options in future. + timeout := constants.DefaultStreamTimeout + payload := input.toAPIPayload(c.entrypointTag, timeout, false) + payload.AsyncExecution = false + + data, err := json.Marshal(payload) + if err != nil { + return nil, newError(ErrorTypeValidation, "failed to serialize stream payload", withCause(err)) + } + + if !c.local && c.apiKey == "" { + return nil, newError( + ErrorTypeAuthentication, + "api_key is required for remote streaming", + withSuggestion("Set RUNAGENT_API_KEY or pass Config.APIKey"), + ) + } + + endpoint := fmt.Sprintf("%s/agents/%s/run-stream", c.baseSocketURL, c.agentID) + if !c.local && c.apiKey != "" { + endpoint = appendToken(endpoint, c.apiKey) + } + + dialer := websocket.Dialer{ + HandshakeTimeout: 30 * time.Second, + } + + headers := http.Header{ + "User-Agent": []string{userAgent()}, + } + + conn, _, err := dialer.DialContext(ctx, endpoint, headers) + if err != nil { + return nil, newError( + ErrorTypeConnection, + "failed to open WebSocket connection", + withCause(err), + ) + } + + if err := conn.WriteMessage(websocket.TextMessage, data); err != nil { + conn.Close() + return nil, newError(ErrorTypeConnection, "failed to send stream bootstrap payload", withCause(err)) + } + + return newStreamIterator(conn), nil +} + +// RunStreamNative starts a streaming execution using native Go-shaped arguments. +func (c *RunAgentClient) RunStreamNative(ctx context.Context, values ...any) (*StreamIterator, error) { + input, err := coerceToRunInput(values...) + if err != nil { + return nil, err + } + return c.RunStream(ctx, input) +} + +// ExtraParams returns the extra metadata provided at construction. +func (c *RunAgentClient) ExtraParams() map[string]interface{} { + copyMap := make(map[string]interface{}, len(c.extraParams)) + for k, v := range c.extraParams { + copyMap[k] = v + } + return copyMap +} + +func parseRunResponse(status int, body []byte) (interface{}, error) { + var envelope map[string]interface{} + if err := json.Unmarshal(body, &envelope); err != nil { + // Allow plain-string outputs. + return decodeStructuredString(string(body)), nil + } + + if errPayload := extractAPIError(envelope); errPayload != nil { + return nil, newExecutionError(status, errPayload) + } + + if data, ok := envelope["data"]; ok { + if result := unwrapDataField(data); result != nil { + // If the result is a structured object with payload, normalize it further + if m, ok := result.(map[string]interface{}); ok { + normalized := decodeStructuredObject(m) + return normalized, nil + } + return result, nil + } + } + + // Payload-only structured responses + if payload, exists := envelope["payload"]; exists { + switch p := payload.(type) { + case string: + decoded := decodeStructuredString(p) + return decoded, nil + default: + return p, nil + } + } + + if outputData, ok := envelope["output_data"]; ok { + return outputData, nil + } + + return envelope, nil +} + +func extractAPIError(envelope map[string]interface{}) *apiErrorPayload { + if envelope == nil { + return nil + } + + if rawErr, ok := envelope["error"]; ok { + if parsed := parseAPIError(rawErr); parsed != nil { + return parsed + } + } + + if success, ok := envelope["success"].(bool); ok && success { + return nil + } + + if success, ok := envelope["success"].(bool); ok && !success { + message := "agent execution failed" + if msg, ok := envelope["message"].(string); ok && msg != "" { + message = msg + } + return &apiErrorPayload{ + Type: ErrorTypeServer, + Message: message, + } + } + + return nil +} + +func parseAPIError(raw interface{}) *apiErrorPayload { + switch val := raw.(type) { + case nil: + return nil + case string: + return &apiErrorPayload{ + Type: ErrorTypeServer, + Message: val, + } + case map[string]interface{}: + payload := &apiErrorPayload{ + Type: ErrorTypeServer, + } + + if t, ok := val["type"].(string); ok && t != "" { + payload.Type = ErrorType(t) + } + if msg, ok := val["message"].(string); ok { + payload.Message = msg + } + if code, ok := val["code"].(string); ok { + payload.Code = code + } + if suggestion, ok := val["suggestion"].(string); ok { + payload.Suggestion = suggestion + } + if details, ok := val["details"].(map[string]interface{}); ok { + payload.Details = details + } + return payload + default: + return &apiErrorPayload{ + Type: ErrorTypeServer, + Message: fmt.Sprintf("%v", val), + } + } +} + +func unwrapDataField(data interface{}) interface{} { + switch typed := data.(type) { + case string: + return decodeStructuredString(typed) + case map[string]interface{}: + if resultData, ok := typed["result_data"].(map[string]interface{}); ok { + if inner, exists := resultData["data"]; exists { + return inner + } + } + if inner, ok := typed["data"]; ok { + return inner + } + if inner, ok := typed["content"]; ok { + return inner + } + // Structured object with payload field + return decodeStructuredObject(typed) + default: + return typed + } +} + +type envConfig struct { + apiKey string + baseURL string + host string + port int + timeoutSeconds int + local *bool +} + +func loadEnvConfig() envConfig { + cfg := envConfig{} + cfg.apiKey = strings.TrimSpace(os.Getenv(constants.EnvAPIKey)) + cfg.baseURL = strings.TrimSpace(os.Getenv(constants.EnvBaseURL)) + cfg.host = strings.TrimSpace(os.Getenv(constants.EnvAgentHost)) + + if portStr := os.Getenv(constants.EnvAgentPort); portStr != "" { + if port, err := strconv.Atoi(portStr); err == nil { + cfg.port = port + } + } + + if timeoutStr := os.Getenv(constants.EnvTimeout); timeoutStr != "" { + if timeout, err := strconv.Atoi(timeoutStr); err == nil { + cfg.timeoutSeconds = timeout + } + } + + if localStr := os.Getenv(constants.EnvLocalAgent); localStr != "" { + if local, err := strconv.ParseBool(localStr); err == nil { + cfg.local = &local + } + } + + return cfg +} + +func discoverLocalAgent(agentID string) (string, int, error) { + svc, err := db.NewService("") + if err != nil { + return "", 0, newError(ErrorTypeConnection, "failed to open local agent registry", withCause(err)) + } + defer svc.Close() + + agent, err := svc.GetAgent(agentID) + if err != nil { + return "", 0, newError(ErrorTypeServer, "failed to lookup agent in local database", withCause(err)) + } + if agent == nil { + return "", 0, newError( + ErrorTypeValidation, + fmt.Sprintf("agent %s was not found locally", agentID), + withSuggestion("Start the agent locally or pass host/port overrides"), + ) + } + + return agent.Host, agent.Port, nil +} + +func normalizeRemoteBases(raw string) (string, string, error) { + if raw == "" { + raw = constants.DefaultBaseURL + } + + if !strings.HasPrefix(raw, "http://") && !strings.HasPrefix(raw, "https://") { + raw = "https://" + raw + } + + trimmed := strings.TrimSuffix(raw, "/") + + restBase := trimmed + constants.DefaultAPIPrefix + + var socketBase string + switch { + case strings.HasPrefix(trimmed, "https://"): + socketBase = "wss://" + strings.TrimPrefix(trimmed, "https://") + constants.DefaultAPIPrefix + case strings.HasPrefix(trimmed, "http://"): + socketBase = "ws://" + strings.TrimPrefix(trimmed, "http://") + constants.DefaultAPIPrefix + default: + return "", "", newError(ErrorTypeValidation, fmt.Sprintf("invalid base URL: %s", raw)) + } + + return restBase, socketBase, nil +} + +func resolveBool(explicit *bool, fallback *bool, defaultValue bool) bool { + switch { + case explicit != nil: + return *explicit + case fallback != nil: + return *fallback + default: + return defaultValue + } +} + +func firstNonEmpty(values ...string) string { + for _, candidate := range values { + if strings.TrimSpace(candidate) != "" { + return strings.TrimSpace(candidate) + } + } + return "" +} + +func firstNonZero(values ...int) int { + for _, candidate := range values { + if candidate > 0 { + return candidate + } + } + return 0 +} + +func appendToken(uri, token string) string { + if token == "" { + return uri + } + parsed, err := url.Parse(uri) + if err != nil { + return uri + } + query := parsed.Query() + query.Set("token", token) + parsed.RawQuery = query.Encode() + return parsed.String() +} + +func translateHTTPError(status int, body []byte) error { + apiErr := &apiErrorPayload{ + Type: ErrorTypeServer, + Message: fmt.Sprintf("server returned status %d", status), + } + + var payload map[string]interface{} + if err := json.Unmarshal(body, &payload); err == nil { + if parsed := extractAPIError(payload); parsed != nil { + apiErr = enrichErrorPayload(parsed) + } + } + + if status == http.StatusUnauthorized || status == http.StatusForbidden { + apiErr.Type = ErrorTypeAuthentication + if apiErr.Suggestion == "" { + apiErr.Suggestion = "Set RUNAGENT_API_KEY or pass Config.APIKey" + } + } else if status >= 500 { + apiErr.Type = ErrorTypeServer + } + + return newExecutionError(status, apiErr) +} + +func userAgent() string { + return fmt.Sprintf("runagent-go/%s", Version) +} + +// ---- Flexible argument tokens and coercion ---- + +type argToken struct{ v any } +type argsToken struct{ v []any } +type kwToken struct { + k string + v any +} +type kwsToken struct{ m map[string]any } + +// Arg appends one positional argument. +func Arg(v any) argToken { return argToken{v: v} } + +// Args appends multiple positional arguments. +func Args(v ...any) argsToken { return argsToken{v: v} } + +// Kw adds one keyword argument. +func Kw(key string, value any) kwToken { return kwToken{k: key, v: value} } + +// Kws merges many keyword arguments from a map. +func Kws(m map[string]any) kwsToken { return kwsToken{m: m} } + +func coerceToRunInput(values ...any) (RunInput, error) { + var input RunInput + var haveArgs bool + var haveKw bool + + appendArg := func(v any) { + input.InputArgs = append(input.InputArgs, v) + haveArgs = true + } + addKw := func(k string, v any) { + if input.InputKwargs == nil { + input.InputKwargs = map[string]any{} + } + input.InputKwargs[k] = v + haveKw = true + } + + for _, v := range values { + switch t := v.(type) { + case argToken: + appendArg(t.v) + case argsToken: + for _, item := range t.v { + appendArg(item) + } + case kwToken: + addKw(t.k, t.v) + case kwsToken: + for k, val := range t.m { + addKw(k, val) + } + case map[string]any: + for k, val := range t { + addKw(k, val) + } + default: + // Reject raw []any to avoid ambiguity with Args(...). + if isSliceOfAny(t) { + return RunInput{}, newError( + ErrorTypeValidation, + "pass positional slice via Args(...), not raw []any", + withSuggestion("Use runagent.Args(v1, v2, ...)"), + ) + } + // Structs→kwargs via json round-trip; primitives→single arg. + if isStructLike(t) { + m, err := structToMap(t) + if err != nil { + return RunInput{}, newError(ErrorTypeValidation, "failed to encode struct into kwargs", withCause(err)) + } + for k, val := range m { + addKw(k, val) + } + } else { + appendArg(t) + } + } + } + + // Ensure non-nil fields + if !haveArgs { + input.InputArgs = []any{} + } + if !haveKw { + input.InputKwargs = map[string]any{} + } + + return input, nil +} + +func isSliceOfAny(v any) bool { + rv := reflect.ValueOf(v) + return rv.IsValid() && rv.Kind() == reflect.Slice && rv.Type().Elem().Kind() == reflect.Interface +} + +func isStructLike(v any) bool { + rv := reflect.ValueOf(v) + if !rv.IsValid() { + return false + } + k := rv.Kind() + return k == reflect.Struct +} + +func structToMap(v any) (map[string]any, error) { + b, err := json.Marshal(v) + if err != nil { + return nil, err + } + var m map[string]any + if err := json.Unmarshal(b, &m); err != nil { + return nil, err + } + return m, nil +} + +// GetArchitecture fetches the agent architecture and normalizes both envelope and legacy formats. +func (c *RunAgentClient) GetArchitecture(ctx context.Context) (*AgentArchitecture, error) { + endpoint := fmt.Sprintf("%s/agents/%s/architecture", c.baseRESTURL, c.agentID) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, newError(ErrorTypeUnknown, "failed to create request", withCause(err)) + } + if !c.local { + if c.apiKey == "" { + return nil, newError( + ErrorTypeAuthentication, + "api_key is required for remote calls", + withSuggestion("Set RUNAGENT_API_KEY or pass Config.APIKey"), + ) + } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.apiKey)) + } + req.Header.Set("User-Agent", userAgent()) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, newError(ErrorTypeConnection, "failed to reach RunAgent service", withCause(err)) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, newError(ErrorTypeUnknown, "failed to read response body", withCause(err)) + } + + if resp.StatusCode != http.StatusOK { + return nil, translateHTTPError(resp.StatusCode, body) + } + + // Try envelope format + var envelope struct { + Success bool `json:"success"` + Data struct { + AgentID string `json:"agent_id"` + Entrypoints []EntryPoint `json:"entrypoints"` + } `json:"data"` + Message string `json:"message"` + Error interface{} `json:"error"` + } + if err := json.Unmarshal(body, &envelope); err == nil && (envelope.Success || envelope.Message != "" || envelope.Error != nil) { + if envelope.Success { + if len(envelope.Data.Entrypoints) == 0 { + return nil, newError( + ErrorTypeValidation, + "architecture missing entrypoints", + withCode("ARCHITECTURE_MISSING"), + withSuggestion("Redeploy the agent with entrypoints configured"), + ) + } + return &AgentArchitecture{ + AgentID: envelope.Data.AgentID, + Entrypoints: envelope.Data.Entrypoints, + }, nil + } + if apiErr := parseAPIError(envelope.Error); apiErr != nil { + return nil, newExecutionError(resp.StatusCode, apiErr) + } + return nil, newError(ErrorTypeServer, "failed to retrieve agent architecture") + } + + // Fallback to legacy + var legacy AgentArchitecture + if err := json.Unmarshal(body, &legacy); err != nil { + return nil, newError(ErrorTypeUnknown, "failed to decode architecture", withCause(err)) + } + if len(legacy.Entrypoints) == 0 { + return nil, newError( + ErrorTypeValidation, + "architecture missing entrypoints", + withCode("ARCHITECTURE_MISSING"), + withSuggestion("Redeploy the agent with entrypoints configured"), + ) + } + return &legacy, nil +} diff --git a/runagent-go/runagent/errors.go b/runagent-go/runagent/errors.go new file mode 100644 index 0000000..d6ad18e --- /dev/null +++ b/runagent-go/runagent/errors.go @@ -0,0 +1,169 @@ +package runagent + +import ( + "fmt" + "strings" +) + +// ErrorType captures the standardized error taxonomy shared across SDKs. +type ErrorType string + +const ( + ErrorTypeAuthentication ErrorType = "AUTHENTICATION_ERROR" + ErrorTypePermission ErrorType = "PERMISSION_ERROR" + ErrorTypeConnection ErrorType = "CONNECTION_ERROR" + ErrorTypeValidation ErrorType = "VALIDATION_ERROR" + ErrorTypeServer ErrorType = "SERVER_ERROR" + ErrorTypeUnknown ErrorType = "UNKNOWN_ERROR" +) + +// RunAgentError is the root error type returned by the Go SDK. +type RunAgentError struct { + Type ErrorType + Code string + Message string + Suggestion string + Details map[string]interface{} + Cause error +} + +func (e *RunAgentError) Error() string { + if e == nil { + return "" + } + + base := fmt.Sprintf("%s: %s", e.Type, e.Message) + if e.Code != "" { + base = fmt.Sprintf("%s (%s)", base, e.Code) + } + if e.Suggestion != "" { + base = fmt.Sprintf("%s | suggestion: %s", base, e.Suggestion) + } + return base +} + +// Unwrap exposes the wrapped cause when available. +func (e *RunAgentError) Unwrap() error { + if e == nil { + return nil + } + return e.Cause +} + +// RunAgentExecutionError represents errors returned by the RunAgent service. +type RunAgentExecutionError struct { + *RunAgentError + HTTPStatus int +} + +func newError(kind ErrorType, message string, opts ...func(*RunAgentError)) *RunAgentError { + err := &RunAgentError{ + Type: kind, + Message: message, + } + for _, opt := range opts { + opt(err) + } + return err +} + +func withCode(code string) func(*RunAgentError) { + return func(e *RunAgentError) { + e.Code = code + } +} + +func withSuggestion(s string) func(*RunAgentError) { + return func(e *RunAgentError) { + e.Suggestion = s + } +} + +func withDetails(details map[string]interface{}) func(*RunAgentError) { + return func(e *RunAgentError) { + e.Details = details + } +} + +func withCause(err error) func(*RunAgentError) { + return func(e *RunAgentError) { + e.Cause = err + } +} + +func newExecutionError(status int, apiErr *apiErrorPayload) *RunAgentExecutionError { + if apiErr == nil { + apiErr = &apiErrorPayload{ + Type: ErrorTypeUnknown, + Message: "agent execution failed", + } + } + + apiErr = enrichErrorPayload(apiErr) + + runErr := &RunAgentError{ + Type: apiErr.Type, + Code: apiErr.Code, + Message: apiErr.Message, + Suggestion: apiErr.Suggestion, + Details: apiErr.Details, + } + if runErr.Type == "" { + runErr.Type = ErrorTypeServer + } + return &RunAgentExecutionError{ + RunAgentError: runErr, + HTTPStatus: status, + } +} + +// enrichErrorPayload adds friendly suggestions for common error shapes. +func enrichErrorPayload(e *apiErrorPayload) *apiErrorPayload { + if e == nil { + return nil + } + msg := strings.ToLower(e.Message) + code := strings.ToUpper(e.Code) + + switch { + case strings.Contains(msg, "unexpected keyword argument"): + if e.Suggestion == "" { + e.Suggestion = "Check the entrypoint's expected parameter names. If your agent expects 'message', pass Kw(\"message\", ...)." + } + case strings.Contains(msg, "entrypoint") && strings.Contains(msg, "not found"): + if e.Suggestion == "" { + e.Suggestion = "Verify the entrypoint tag and use GetArchitecture(ctx) to list available tags." + } + case code == "AUTHENTICATION_ERROR": + if e.Suggestion == "" { + e.Suggestion = "Set RUNAGENT_API_KEY or pass Config.APIKey for remote calls." + } + case code == "NON_STREAM_ENTRYPOINT": + if e.Suggestion == "" { + e.Suggestion = "Use client.Run(...) for non-stream tags." + } + case code == "STREAM_ENTRYPOINT": + if e.Suggestion == "" { + e.Suggestion = "Use client.RunStream(...) for *_stream tags." + } + } + return e +} + +// formatFriendlyError renders a helpful panic message including suggestions when available. +func formatFriendlyError(err error) string { + switch e := err.(type) { + case *RunAgentExecutionError: + if e.Suggestion != "" { + return fmt.Sprintf("RunAgent error: %s (%s)\nSuggestion: %s", e.Message, e.Type, e.Suggestion) + } + return fmt.Sprintf("RunAgent error: %s (%s)", e.Message, e.Type) + case *RunAgentError: + if e.Suggestion != "" { + return fmt.Sprintf("RunAgent error: %s (%s)\nSuggestion: %s", e.Message, e.Type, e.Suggestion) + } + return fmt.Sprintf("RunAgent error: %s (%s)", e.Message, e.Type) + default: + return fmt.Sprintf("RunAgent error: %v", err) + } +} diff --git a/runagent-go/runagent/pkg/constants/constants.go b/runagent-go/runagent/pkg/constants/constants.go index f97be5d..09bf777 100644 --- a/runagent-go/runagent/pkg/constants/constants.go +++ b/runagent-go/runagent/pkg/constants/constants.go @@ -14,16 +14,27 @@ const ( DefaultTemplate = "basic" // Environment variables - EnvAPIKey = "RUNAGENT_API_KEY" - EnvBaseURL = "RUNAGENT_BASE_URL" - EnvCacheDir = "RUNAGENT_CACHE_DIR" - EnvLogLevel = "RUNAGENT_LOGGING_LEVEL" + EnvAPIKey = "RUNAGENT_API_KEY" + EnvBaseURL = "RUNAGENT_BASE_URL" + EnvCacheDir = "RUNAGENT_CACHE_DIR" + EnvLogLevel = "RUNAGENT_LOGGING_LEVEL" + EnvLocalAgent = "RUNAGENT_LOCAL" + EnvAgentHost = "RUNAGENT_HOST" + EnvAgentPort = "RUNAGENT_PORT" + EnvTimeout = "RUNAGENT_TIMEOUT" // Default values - DefaultBaseURL = "http://52.237.88.147:8330/" - AgentConfigFileName = "runagent.config.json" - UserDataFileName = "user_data.json" - DatabaseFileName = "runagent_local.db" + DefaultBaseURL = "https://backend.run-agent.ai" + DefaultAPIPrefix = "/api/v1" + DefaultSocketProtocol = "wss" + DefaultRESTProtocol = "https" + DefaultLocalHost = "127.0.0.1" + DefaultLocalPort = 8450 + DefaultTimeoutSeconds = 300 + DefaultStreamTimeout = 600 + AgentConfigFileName = "runagent.config.json" + UserDataFileName = "user_data.json" + DatabaseFileName = "runagent_local.db" // Port configuration DefaultPortStart = 8450 diff --git a/runagent-go/runagent/pkg/db/db.go b/runagent-go/runagent/pkg/db/db.go index 696bfee..f3bc5a0 100644 --- a/runagent-go/runagent/pkg/db/db.go +++ b/runagent-go/runagent/pkg/db/db.go @@ -8,7 +8,7 @@ import ( "time" _ "github.com/mattn/go-sqlite3" - "github.com/runagent-dev/runagent-go/pkg/constants" + "github.com/runagent-dev/runagent/runagent-go/runagent/pkg/constants" ) // Agent represents an agent in the database diff --git a/runagent-go/runagent/pkg/server/server.go b/runagent-go/runagent/pkg/server/server.go index c90863b..46ffb9f 100644 --- a/runagent-go/runagent/pkg/server/server.go +++ b/runagent-go/runagent/pkg/server/server.go @@ -9,7 +9,7 @@ import ( "time" "github.com/gorilla/mux" - "github.com/runagent-dev/runagent-go/pkg/types" + "github.com/runagent-dev/runagent/runagent-go/runagent/pkg/types" ) // Server represents a local RunAgent server diff --git a/runagent-go/runagent/pkg/utils/port.go b/runagent-go/runagent/pkg/utils/port.go index fec3553..a048114 100644 --- a/runagent-go/runagent/pkg/utils/port.go +++ b/runagent-go/runagent/pkg/utils/port.go @@ -4,7 +4,7 @@ import ( "fmt" "net" - "github.com/runagent-dev/runagent-go/pkg/constants" + "github.com/runagent-dev/runagent/runagent-go/runagent/pkg/constants" ) // PortManager manages port allocation diff --git a/runagent-go/runagent/stream.go b/runagent-go/runagent/stream.go new file mode 100644 index 0000000..ae33f5b --- /dev/null +++ b/runagent-go/runagent/stream.go @@ -0,0 +1,229 @@ +package runagent + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/gorilla/websocket" +) + +// StreamIterator provides a blocking iterator over streaming responses. +type StreamIterator struct { + conn *websocket.Conn + closed bool +} + +func newStreamIterator(conn *websocket.Conn) *StreamIterator { + return &StreamIterator{conn: conn} +} + +// Next blocks until the next chunk is available. The boolean indicates whether more data is expected. +func (s *StreamIterator) Next(ctx context.Context) (interface{}, bool, error) { + if s.closed { + return nil, false, nil + } + + for { + select { + case <-ctx.Done(): + s.Close() + return nil, false, ctx.Err() + default: + } + + _, msg, err := s.conn.ReadMessage() + if err != nil { + s.Close() + return nil, false, newError( + ErrorTypeConnection, + "failed to read stream message", + withCause(err), + ) + } + var frame streamFrame + if err := json.Unmarshal(msg, &frame); err != nil { + s.Close() + return nil, false, newError(ErrorTypeServer, "invalid stream message", withCause(err)) + } + + // Uniform error detection across frame shapes - panic immediately on error frames + if len(frame.Error) > 0 && string(frame.Error) != "null" { + err := newExecutionError(0, enrichErrorPayload(parseFrameError(frame))) + s.Close() + panic(formatFriendlyError(err)) + } + if strings.EqualFold(frame.Type, "error") { + err := newExecutionError(0, enrichErrorPayload(parseFrameError(frame))) + s.Close() + panic(formatFriendlyError(err)) + } + // Detect status strings that indicate failure - panic immediately + if frame.Status != "" { + status := strings.ToLower(frame.Status) + if strings.Contains(status, "error") || strings.Contains(status, "fail") || strings.Contains(status, "failed") { + err := newExecutionError(0, enrichErrorPayload(parseFrameError(frame))) + s.Close() + panic(formatFriendlyError(err)) + } + } + + switch strings.ToLower(frame.Type) { + case "status": + status := strings.ToLower(frame.Status) + switch status { + case "stream_started": + continue + case "error", "stream_error", "failed", "stream_failed": + err := newExecutionError(0, enrichErrorPayload(parseFrameError(frame))) + s.Close() + panic(formatFriendlyError(err)) + case "stream_completed": + s.Close() + return nil, false, nil + default: + continue + } + case "error": + err := newExecutionError(0, enrichErrorPayload(parseFrameError(frame))) + s.Close() + panic(formatFriendlyError(err)) + case "data": + payload, err := decodeStreamPayload(frame) + if err != nil { + s.Close() + return nil, false, err + } + // If the payload itself encodes an error object, panic immediately + if m, ok := payload.(map[string]interface{}); ok { + // Some servers put error info inside the data envelope + if rawErr, ok := m["error"]; ok && rawErr != nil { + api := enrichErrorPayload(parseAPIError(rawErr)) + err := newExecutionError(0, api) + s.Close() + panic(formatFriendlyError(err)) + } + if t, ok := m["type"].(string); ok && strings.EqualFold(t, "error") { + // try to lift message/suggestion if present + api := &apiErrorPayload{ + Type: ErrorTypeServer, + Message: fmt.Sprint(m["message"]), + Code: fmt.Sprint(m["code"]), + } + err := newExecutionError(0, enrichErrorPayload(api)) + s.Close() + panic(formatFriendlyError(err)) + } + } + return payload, true, nil + default: + // Treat unknown types as data for forward compatibility. + payload, err := decodeStreamPayload(frame) + if err != nil { + s.Close() + return nil, false, err + } + // Also inspect unknown payloads for embedded errors - panic immediately + if m, ok := payload.(map[string]interface{}); ok { + if rawErr, ok := m["error"]; ok && rawErr != nil { + api := enrichErrorPayload(parseAPIError(rawErr)) + err := newExecutionError(0, api) + s.Close() + panic(formatFriendlyError(err)) + } + if t, ok := m["type"].(string); ok && strings.EqualFold(t, "error") { + api := &apiErrorPayload{ + Type: ErrorTypeServer, + Message: fmt.Sprint(m["message"]), + Code: fmt.Sprint(m["code"]), + } + err := newExecutionError(0, enrichErrorPayload(api)) + s.Close() + panic(formatFriendlyError(err)) + } + } + return payload, true, nil + } + } +} + +// Close terminates the underlying WebSocket connection. +func (s *StreamIterator) Close() error { + if s.closed { + return nil + } + s.closed = true + return s.conn.Close() +} + +// NextOrPanic is a convenience wrapper that panics on error with a user-friendly message. +// Use this only in quickstarts or CLI-like apps where panicking is acceptable behavior. +func (s *StreamIterator) NextOrPanic(ctx context.Context) interface{} { + chunk, more, err := s.Next(ctx) + if err != nil { + panic(formatFriendlyError(err)) + } + if !more { + panic("RunAgent stream: terminated unexpectedly before completion") + } + return chunk +} + +func decodeStreamPayload(frame streamFrame) (interface{}, error) { + raw := frame.Content + if len(raw) == 0 { + raw = frame.Data + } + if len(raw) == 0 { + return nil, nil + } + + var payload interface{} + if err := json.Unmarshal(raw, &payload); err != nil { + // Fall back to raw string. + return string(raw), nil + } + + switch v := payload.(type) { + case map[string]interface{}: + // Some servers send { "type": "data", "data": { "content": ... } } + if content, ok := v["content"]; ok { + return content, nil + } + // payload-aware normalization + decoded := decodeStructuredObject(v) + return decoded, nil + default: + // If the payload is a stringified structured object, decode it + if str, ok := v.(string); ok { + decoded := decodeStructuredString(str) + // If decoded is a map with payload, normalize it + if m, isMap := decoded.(map[string]interface{}); isMap { + n := decodeStructuredObject(m) + return n, nil + } + return decoded, nil + } + return v, nil + } +} + +func parseFrameError(frame streamFrame) *apiErrorPayload { + if len(frame.Error) == 0 { + return &apiErrorPayload{ + Type: ErrorTypeServer, + Message: "stream failed", + } + } + + var payload interface{} + if err := json.Unmarshal(frame.Error, &payload); err != nil { + return &apiErrorPayload{ + Type: ErrorTypeServer, + Message: fmt.Sprintf("stream error: %s", string(frame.Error)), + } + } + + return parseAPIError(payload) +} diff --git a/runagent-go/runagent/types.go b/runagent-go/runagent/types.go new file mode 100644 index 0000000..55a1bcb --- /dev/null +++ b/runagent-go/runagent/types.go @@ -0,0 +1,143 @@ +package runagent + +import ( + "encoding/json" + "fmt" + "net/http" +) + +// Config captures initialization options for RunAgentClient. +// Field precedence: explicit Config values override environment variables, +// which override library defaults. +type Config struct { + AgentID string + EntrypointTag string + Local *bool + Host string + Port int + BaseURL string + APIKey string + TimeoutSeconds int + AsyncExecution *bool + ExtraParams map[string]interface{} + HTTPClient *http.Client +} + +// RunInput describes a run invocation payload. +type RunInput struct { + InputArgs []interface{} + InputKwargs map[string]interface{} + TimeoutSeconds int + AsyncExecution *bool +} + +// StreamOptions allow customizing RunStream behavior. +type StreamOptions struct { + TimeoutSeconds int +} + +// Bool is a helper to create *bool literals inline. +func Bool(v bool) *bool { return &v } + +type apiRunRequest struct { + EntrypointTag string `json:"entrypoint_tag"` + InputArgs []interface{} `json:"input_args"` + InputKwargs map[string]interface{} `json:"input_kwargs"` + TimeoutSeconds int `json:"timeout_seconds"` + AsyncExecution bool `json:"async_execution,omitempty"` +} + +type apiErrorPayload struct { + Type ErrorType `json:"type"` + Code string `json:"code"` + Message string `json:"message"` + Suggestion string `json:"suggestion"` + Details map[string]interface{} `json:"details"` +} + +type streamFrame struct { + Type string `json:"type"` + Status string `json:"status"` + Content json.RawMessage `json:"content"` + Data json.RawMessage `json:"data"` + Error json.RawMessage `json:"error"` +} + +// EntryPoint describes a deployable entrypoint. +type EntryPoint struct { + File string `json:"file,omitempty"` + Module string `json:"module,omitempty"` + Tag string `json:"tag"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + Extractor map[string]interface{} `json:"extractor,omitempty"` +} + +// AgentArchitecture provides entrypoint metadata for an agent. +type AgentArchitecture struct { + AgentID string `json:"agent_id,omitempty"` + Entrypoints []EntryPoint `json:"entrypoints"` +} + +func (i RunInput) toAPIPayload(entrypoint string, fallbackTimeout int, defaultAsync bool) apiRunRequest { + timeout := fallbackTimeout + if i.TimeoutSeconds > 0 { + timeout = i.TimeoutSeconds + } + + async := defaultAsync + if i.AsyncExecution != nil { + async = *i.AsyncExecution + } + + args := i.InputArgs + if args == nil { + args = []interface{}{} + } + + kwargs := i.InputKwargs + if kwargs == nil { + kwargs = map[string]interface{}{} + } + + return apiRunRequest{ + EntrypointTag: entrypoint, + InputArgs: args, + InputKwargs: kwargs, + TimeoutSeconds: timeout, + AsyncExecution: async, + } +} + +func decodeStructuredString(value string) interface{} { + if value == "" { + return value + } + + var decoded interface{} + if err := json.Unmarshal([]byte(value), &decoded); err == nil { + return decoded + } + + var unquoted string + if err := json.Unmarshal([]byte(fmt.Sprintf("%q", value)), &unquoted); err == nil { + return unquoted + } + + return value +} + +// decodeStructuredObject handles objects that may contain a "payload" field +// where payload can be a stringified JSON or native JSON. This mirrors the +// normalization in other SDKs so callers get the inner content directly. +func decodeStructuredObject(obj map[string]interface{}) interface{} { + if payload, ok := obj["payload"]; ok { + switch p := payload.(type) { + case string: + return decodeStructuredString(p) + default: + return p + } + } + return obj +} diff --git a/runagent-go/runagent/version.go b/runagent-go/runagent/version.go index 1f83b04..5c0ea3f 100644 --- a/runagent-go/runagent/version.go +++ b/runagent-go/runagent/version.go @@ -1,4 +1,4 @@ package runagent // Version represents the current version of the RunAgent Go SDK -const Version = "0.1.33" +const Version = "0.1.36" diff --git a/runagent-rust/runagent/Cargo.toml b/runagent-rust/runagent/Cargo.toml index 07cc759..0f2b192 100644 --- a/runagent-rust/runagent/Cargo.toml +++ b/runagent-rust/runagent/Cargo.toml @@ -1,12 +1,12 @@ [package] name = "runagent" -version = "0.1.34" +version = "0.1.36" edition = "2021" description = "RunAgent SDK for Rust - Client SDK for interacting with deployed AI agents" license = "MIT" repository = "https://github.com/runagent-dev/runagent" -homepage = "https://runagent.ai" -documentation = "https://docs.rs/runagent" +homepage = "https://run-agent.ai" +documentation = "https://docs.run-agent.ai" readme = "README.md" keywords = ["ai", "agents", "llm", "sdk", "deployment"] categories = ["api-bindings", "development-tools", "web-programming"] diff --git a/runagent-ts/package-lock.json b/runagent-ts/package-lock.json index e1b407d..0f53456 100644 --- a/runagent-ts/package-lock.json +++ b/runagent-ts/package-lock.json @@ -1,12 +1,12 @@ { "name": "runagent", - "version": "0.1.33", + "version": "0.1.36", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "runagent", - "version": "0.1.33", + "version": "0.1.36", "dependencies": { "better-sqlite3": "^12.2.0" }, diff --git a/runagent-ts/package.json b/runagent-ts/package.json index c710750..eb3fb6b 100644 --- a/runagent-ts/package.json +++ b/runagent-ts/package.json @@ -1,6 +1,6 @@ { "name": "runagent", - "version": "0.1.33", + "version": "0.1.36", "type": "module", "files": [ "dist" diff --git a/runagent/__init__.py b/runagent/__init__.py index 800b4ff..3a53626 100644 --- a/runagent/__init__.py +++ b/runagent/__init__.py @@ -5,7 +5,7 @@ built with frameworks like LangGraph, LangChain, and LlamaIndex. """ -__version__ = "0.1.33" +__version__ = "0.1.36" from .client import RunAgentClient diff --git a/runagent/__version__.py b/runagent/__version__.py index c2e5539..8577c83 100644 --- a/runagent/__version__.py +++ b/runagent/__version__.py @@ -1 +1 @@ -__version__ = "0.1.33" +__version__ = "0.1.36"