Skip to content
Open
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
2 changes: 1 addition & 1 deletion cmd/oc/internal/commands/agent_crud.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ var agentGetCmd = &cobra.Command{
func registerAgentCrud() {
agentCreateCmd.Flags().String("prompt", "", "System prompt (required, except --runtime flue)")
agentCreateCmd.Flags().String("model", "", "Model, e.g. anthropic/claude-sonnet-5 (required)")
agentCreateCmd.Flags().String("runtime", "claude", "Runtime family (claude|codex|pi|flue)")
agentCreateCmd.Flags().String("runtime", "claude", "Runtime family (claude|codex|pi|flue|langgraph)")
agentCreateCmd.Flags().String("credential", "", "Credential id (optional)")
agentGetCmd.Flags().String("agent", "", "Agent id or name (else the cwd agent.toml)")

Expand Down
5 changes: 5 additions & 0 deletions cmd/oc/internal/commands/agent_deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,11 @@ var agentDeployCmd = &cobra.Command{
noActivate, _ := cmd.Flags().GetBool("no-activate")
return deployFlue(cmd, sc, dir, m, noActivate)
}
// LangGraph agents likewise deploy a built Worker artifact (its own runtime).
if m.Runtime.Family == "langgraph" {
noActivate, _ := cmd.Flags().GetBool("no-activate")
return deployLangGraph(cmd, sc, dir, m, noActivate)
}
prompt, err := readPrompt(dir)
if err != nil {
return fmt.Errorf("read prompt.md: %w", err)
Expand Down
107 changes: 107 additions & 0 deletions cmd/oc/internal/commands/agent_deploy_langgraph.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package commands

// LangGraph deploy flow — the standalone langgraph runtime (model A: the graph runs
// inside a self-hosting Worker + one Durable Object per session). Mirrors the flue
// flow (build client-side, stage the bundle in R2 via presigned PUT, POST a byte-free
// deployment) but is independent of flue: the bundle comes from the project's own
// wrangler, and the deployment carries `langgraph_*` fields. The server registers the
// `langgraph` family, then hosts the Worker + binds its session DO like any WfP Worker.

import (
"fmt"
"time"

"github.com/opensandbox/opensandbox/cmd/oc/internal/client"
"github.com/opensandbox/opensandbox/cmd/oc/internal/langgraphbuild"
"github.com/spf13/cobra"
)

func deployLangGraph(cmd *cobra.Command, sc *client.Client, dir string, m *manifest, noActivate bool) error {
// 1. Resolve the target agent first (create with runtime=langgraph + no prompt if
// new) so a runtime-family mismatch fails fast, before the (slow) bundle.
id, err := resolveDeployAgentFamily(cmd, sc, m, "langgraph")
if err != nil {
return err
}
// 2. Persist [vars] before enqueueing so the off-host host can't race ahead with
// stale bindings (secrets stay CLI/API-only, resolved by that same host). Only when
// the manifest declares vars — the config endpoint has no state for a fresh agent.
if len(m.Vars) > 0 {
if err := syncManifestVars(cmd, sc, id, m); err != nil {
return err
}
}

// 3. Bundle the project with its own wrangler → tar.gz + descriptor + digest.
art, err := langgraphbuild.Build(cmd.Context(), dir)
if err != nil {
return err
}

// 4. Upload: presigned PUT to R2 (the API host never sees the bytes).
if err := uploadArtifact(cmd.Context(), sc, id, art.Digest, art.Bundle); err != nil {
return err
}

// 5. Byte-free deployment referencing the R2 bundle + the strict descriptor.
rt := m.Runtime.Type
if rt == "" {
rt = "default"
}
// Post the artifact under the langgraph family field names. The server routes runtime=langgraph
// through the same generic self-hosting WfP-DO deploy pipeline flue uses, normalizing
// langgraph_*/flue_* to one enqueue — so these are the honest langgraph wire fields, not a masquerade.
input := map[string]interface{}{
"type": "inline",
"model": m.Model,
"runtime": map[string]string{"type": rt},
"langgraph_bundle_digest": art.Digest,
"langgraph_wrangler": art.Wrangler,
"langgraph_entrypoint": art.Entrypoint, // the session Durable Object class = admit address
}
body := map[string]interface{}{"input": input, "activate": !noActivate}
if idem, _ := cmd.Flags().GetString("idempotency-key"); idem != "" {
body["idempotency_key"] = idem
}
var env DeploymentEnvelope
if err := sc.Post(cmd.Context(), "/v3/agents/"+id+"/deployments", body, &env); err != nil {
return err
}
d := env.Deployment

// 6. Poll to terminal while the off-host host uploads + finalizes.
if !terminalState(d.State) && d.State != "" {
to, _ := cmd.Flags().GetInt("timeout")
d, err = pollDeployment(cmd, sc, id, d.ID, time.Duration(to)*time.Second)
if err != nil {
return err
}
}
if d.State == "failed" {
printer.Print(d, func() { fmt.Printf("Deploy failed: %s\n", deployFailMsg(d)) })
return &ExitError{Code: 1}
}
printer.Print(d, func() {
n := revisionNumber(cmd, sc, id, d.RevisionID)
status := "staged"
if d.Active {
status = "active"
}
fmt.Printf("Deployed revision %d — %s (%s)\n", n, status, shortDigest(art.Digest))
})
return nil
}

// resolveDeployAgentFamily picks the agent a code-runtime deploy targets: --agent >
// manifest [agent].id > ensure-by-name (creating an agent of the given family, no
// prompt, if absent). Generalizes resolveDeployAgent (which is flue-specific).
func resolveDeployAgentFamily(cmd *cobra.Command, sc *client.Client, m *manifest, family string) (string, error) {
if explicit, _ := cmd.Flags().GetString("agent"); explicit != "" {
return resolveRef(cmd, sc, explicit)
}
if m.Agent.ID != "" {
return m.Agent.ID, nil
}
id, _, err := ensureAgentByName(cmd, sc, m.Name, "", m.Model, family)
return id, err
}
214 changes: 191 additions & 23 deletions cmd/oc/internal/commands/agent_init.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const agentTomlTmpl = `name = %q
model = %q

[runtime]
family = %q # claude | codex | pi
family = %q # claude | codex | pi | flue | langgraph
type = "default"

[limits]
Expand All @@ -35,12 +35,185 @@ Describe what this skill does and when to use it. Delete the skills/ directory
entirely if your agent doesn't need skills.
`

// ── LangGraph (JS) runtime scaffold ──────────────────────────────────────────
// A code runtime (not prompt-based): the agent IS a compiled LangGraph.js graph.
// The scaffold is self-contained and runs locally (npm i && npm run dev); the
// OpenComputer `langgraph` runner drives the exported `graph` per session.

const lgPackageJSON = `{
"name": %q,
"private": true,
"type": "module",
"engines": { "node": ">=22.19.0 <23" },
"scripts": {
"dev": "tsx src/graph.ts",
"typecheck": "tsc --noEmit",
"deploy": "oc agent deploy"
},
"dependencies": {
"@opencomputer/langgraph": "^0.0.1",
"@langchain/langgraph": "^1.4.8",
"@langchain/langgraph-checkpoint": "^1.0.0",
"@langchain/anthropic": "^1.5.1",
"@langchain/core": "^1.2.3"
},
"devDependencies": {
"@types/node": "^22.10.0",
"tsx": "^4.20.0",
"typescript": "^5.9.3",
"wrangler": "^4.0.0"
}
}
`

const lgTsconfig = `{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"types": ["node"]
},
"include": ["src"]
}
`

const lgGitignore = `node_modules/
dist/
.env
.env.*
*.log
.DS_Store
`

// src/graph.ts — the StateGraph. THIS is where you author your agent.
const lgGraphTs = `import { StateGraph, MessagesAnnotation, START, END } from "@langchain/langgraph";
import { HumanMessage } from "@langchain/core/messages";
import type { BaseCheckpointSaver } from "@langchain/langgraph-checkpoint";
import { ocModel } from "@opencomputer/langgraph";

// THIS is where you author your agent. Nodes call models via ocModel(env): env is
// config.configurable.env on deploy (carries the gateway token) and process.env
// locally. Keep 'compile' exported so the runtime injects the durable per-session
// Durable Object checkpointer.
const builder = new StateGraph(MessagesAnnotation)
.addNode("agent", async (state, config) => {
const env = (config?.configurable?.env as Record<string, string | undefined>) ?? process.env;
const res = await ocModel(env).invoke(state.messages);
return { messages: [res] };
})
.addEdge(START, "agent")
.addEdge("agent", END);

export const compile = (checkpointer: BaseCheckpointSaver) => builder.compile({ checkpointer });

// Local smoke test: npm run dev (needs ANTHROPIC_API_KEY).
if (import.meta.url === "file://" + process.argv[1]) {
const { MemorySaver } = await import("@langchain/langgraph");
const graph = compile(new MemorySaver());
const out = await graph.invoke(
{ messages: [new HumanMessage("Say hello in one sentence.")] },
{ configurable: { thread_id: "local" } },
);
console.log((out.messages.at(-1) as { content?: unknown } | undefined)?.content);
}
`

// src/app.ts — the hosting entry: mounts the standalone langgraph runtime.
const lgAppTs = `// Hosting entry for the OpenComputer langgraph runtime. createLangGraphRuntime wires
// the session transport (POST/GET /agents/:agent/:session + /health) and a per-session
// Durable Object that runs your graph with a durable DO-backed checkpointer. Keep both
// exports; LangGraphSession must match the class_name in wrangler.jsonc.
import { createLangGraphRuntime } from "@opencomputer/langgraph";
import { compile } from "./graph.js";

const runtime = createLangGraphRuntime({ compile });

export default { fetch: runtime.fetch };
export const LangGraphSession = runtime.SessionDO;
`

// wrangler.jsonc — the Worker + Durable Object binding for deploy.
const lgWrangler = `{
"name": %q,
"main": "src/app.ts",
"compatibility_date": "2026-07-01",
"compatibility_flags": ["nodejs_compat"],
"durable_objects": {
"bindings": [{ "name": "SESSION", "class_name": "LangGraphSession" }]
},
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["LangGraphSession"] }]
}
`

const lgReadme = `# %s — LangGraph (JS) agent

An OpenComputer agent whose brain is a LangGraph.js ` + "`StateGraph`" + `.

## Author
Edit ` + "`src/graph.ts`" + ` — add nodes, edges, tools, conditional routing. Model calls
go through ` + "`ocModel(env)`" + ` (from @opencomputer/langgraph), which points a LangChain
Anthropic model at the OpenComputer gateway on deploy and at ANTHROPIC_API_KEY
locally. Keep ` + "`compile`" + ` exported — the runtime injects the durable checkpointer.

## Run locally
npm install
ANTHROPIC_API_KEY=sk-ant-... npm run dev

## Deploy
oc agent deploy

## Hosting + durable state
` + "`src/app.ts`" + ` mounts createLangGraphRuntime — its own session transport plus one
Durable Object per session. Local ` + "`npm run dev`" + ` uses an in-memory MemorySaver; on
deploy the runtime injects a DurableObjectSaver automatically, so graph state
persists and resumes across invocations (Postgres/Redis savers can't run on Workers).
`

// scaffoldLangGraph writes a self-contained LangGraph.js project into dir.
func scaffoldLangGraph(dir, name, model string) error {
if err := os.MkdirAll(filepath.Join(dir, "src"), 0o755); err != nil {
return err
}
files := []struct{ path, content string }{
{filepath.Join(dir, "agent.toml"), fmt.Sprintf(agentTomlTmpl, name, model, "langgraph")},
{filepath.Join(dir, "package.json"), fmt.Sprintf(lgPackageJSON, name)},
{filepath.Join(dir, "wrangler.jsonc"), fmt.Sprintf(lgWrangler, name)},
{filepath.Join(dir, "tsconfig.json"), lgTsconfig},
{filepath.Join(dir, ".gitignore"), lgGitignore},
{filepath.Join(dir, "README.md"), fmt.Sprintf(lgReadme, name)},
{filepath.Join(dir, "src", "graph.ts"), lgGraphTs},
{filepath.Join(dir, "src", "app.ts"), lgAppTs},
}
return writeScaffold(files, dir, "Edit src/graph.ts, then: npm install && oc agent deploy")
}

// writeScaffold writes each file (skipping existing), prints progress, and a footer.
func writeScaffold(files []struct{ path, content string }, dir, next string) error {
created := 0
for _, f := range files {
if _, err := os.Stat(f.path); err == nil {
fmt.Printf(" skip %s (exists)\n", f.path)
continue
}
if err := os.WriteFile(f.path, []byte(f.content), 0o644); err != nil {
return err
}
fmt.Printf(" create %s\n", f.path)
created++
}
fmt.Printf("\nScaffolded %d file(s). %s\n", created, next)
return nil
}

var agentInitCmd = &cobra.Command{
Use: "init [dir]",
Short: "Scaffold a deployable agent directory (agent.toml + prompt.md + skills/)",
Short: "Scaffold a deployable agent directory (prompt agent, or a langgraph project)",
Example: " oc agent init\n" +
" oc agent init ./agents/triage --name triage --model anthropic/claude-sonnet-5\n" +
" oc agent init && $EDITOR prompt.md && oc agent deploy",
" oc agent init ./agents/grapher --runtime langgraph",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
dir := "."
Expand All @@ -59,33 +232,28 @@ var agentInitCmd = &cobra.Command{
}
}

if err := os.MkdirAll(filepath.Join(dir, "skills", "example"), 0o755); err != nil {
return err
}
files := []struct{ path, content string }{
{filepath.Join(dir, "agent.toml"), fmt.Sprintf(agentTomlTmpl, name, model, runtime)},
{filepath.Join(dir, "prompt.md"), promptTmpl},
{filepath.Join(dir, "skills", "example", "SKILL.md"), skillTmpl},
}
created := 0
for _, f := range files {
if _, err := os.Stat(f.path); err == nil {
fmt.Printf(" skip %s (exists)\n", f.path)
continue
}
if err := os.WriteFile(f.path, []byte(f.content), 0o644); err != nil {
switch runtime {
case "langgraph":
return scaffoldLangGraph(dir, name, model)
case "flue":
return fmt.Errorf("flue scaffolding not wired yet; see sdks/flue oc-flue-starter (langgraph is: oc agent init --runtime langgraph)")
default:
// Prompt-based runtimes (claude|codex|pi).
if err := os.MkdirAll(filepath.Join(dir, "skills", "example"), 0o755); err != nil {
return err
}
fmt.Printf(" create %s\n", f.path)
created++
files := []struct{ path, content string }{
{filepath.Join(dir, "agent.toml"), fmt.Sprintf(agentTomlTmpl, name, model, runtime)},
{filepath.Join(dir, "prompt.md"), promptTmpl},
{filepath.Join(dir, "skills", "example", "SKILL.md"), skillTmpl},
}
return writeScaffold(files, dir, "Edit prompt.md, then: oc agent deploy "+dir)
}
fmt.Printf("\nScaffolded %d file(s). Edit prompt.md, then: oc agent deploy %s\n", created, dir)
return nil
},
}

func init() {
agentInitCmd.Flags().String("name", "", "Agent name (default: the directory name)")
agentInitCmd.Flags().String("model", "anthropic/claude-sonnet-5", "Model")
agentInitCmd.Flags().String("runtime", "claude", "Runtime family (claude|codex|pi)")
agentInitCmd.Flags().String("runtime", "claude", "Runtime family (claude|codex|pi|flue|langgraph)")
}
Loading
Loading