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
16 changes: 16 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

176 changes: 176 additions & 0 deletions packages/scout-agent/LICENSE

Large diffs are not rendered by default.

67 changes: 67 additions & 0 deletions packages/scout-agent/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# @corbits/scout-agent

Scout, ported from the standalone [scout](https://github.com/corbitsdev/scout)
repo (CL-6499): the system prompt, the tool declarations, and Scout's own
Library-artifact tool — everything one chat agent needs, in one package.

## What's ported and how it's wired

| Original tool | Status | Wired to |
| ------------------------ | ------------------------------ | ---------------------------------------------------------------------------------------------------------------- |
| `knowledge-search` | ported as `memory_search` | `@corbits/memory-tools` (existing, unmodified) |
| `memory-add` | ported as `memory_add` | `@corbits/memory-tools` (existing, unmodified) |
| `memory-list` | ported as `memory_list` | `@corbits/memory-tools` (existing, unmodified) |
| `web-research` | ported as `web_search` | `@corbits/web-search-tools`, Exa-backed, credential handle `"exa"` (existing, unmodified) |
| `artifacts` | narrowed to save + list-recent | `./src/artifact-tool.ts` (new, this package), against `@corbits/artifacts-hub`'s workflow-artifacts HTTP surface |
| `launch-diligence-brief` | **deferred, dropped** | — |
| `launch-fact-check` | **deferred, dropped** | — |

Scout's own tool bodies were never portable as-is: scout's
`docs/design/scout-core-purity.md` says the core/surface split that would
make them portable isn't built yet (CL-5143), and its own
`packages/scout/README.md` says its tools "compile into this repo's
sidecar rather than arriving as pinned, published packages" (CL-5179).
Only the declarations — the system prompt and the tool schemas — are
ported. Everything each tool calls is workbench's own existing
infrastructure.

## Known gaps vs. the original Scout

- **No diligence brief, no fact-check.** The two workflow-launching tools
are gone from both the tool list and the prompt (see
`./src/system-prompt.ts`'s header comment) — a ~2,600-line pipeline
(`workflows/diligence-brief/`, `workflows/diligence/`) that this port
does not carry. This Scout answers questions and remembers things; it
does not run a diligence pipeline. Someone who knows the Slack Scout
will notice this immediately — it's the biggest capability gap.
- **`artifacts` is narrower.** The original backed `artifact-search` (by
company/kind/query, with brief-freshness bands) and `artifact-read`
(paginated open-by-ref) against Scout's own hub API.
`@corbits/artifacts-hub`'s workflow-artifacts HTTP surface only offers
create and list-recent (no search-by-field, no read-by-id, no
pagination), so `save_artifact`/`list_recent_artifacts` is
what's actually available.
- **No per-principal attribution.** The original attributed every
read/write to the Slack-message's triggering principal
(`getTriggerPrincipal()`). This port's tools attribute to whatever
principal/tenant the deploying agent binds at runtime — the same model
every other tool package in this catalog (`@corbits/memory-tools`,
`@corbits/web-search-tools`) already uses, not a Scout-specific
regression.

## Requires connecting

`web_search` needs the Exa MCP preset connected (keyless — no API key,
just add it under Plugins). `save_artifact`/
`list_recent_artifacts` need the Library/artifacts plane mounted
(already true wherever CL-5291 landed). A missing connection surfaces as
an honest `isError: true` tool result naming the gap — never a silent
failure or a fabricated answer.

## Not done in this port

Registering `SCOUT_AGENT_DEFINITION` into a live workbench (the
agent-directory create path, `packages/agent-directory`) is a follow-up,
not part of this package. This package is the portable definition only —
see `corbitsdev/examples`' `starter/agent-quickstart/README.md` for the
`defineAgent`/`createAgent` split this follows.
25 changes: 25 additions & 0 deletions packages/scout-agent/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"name": "@corbits/scout-agent",
"private": true,
"description": "Scout, the research/due-diligence agent: a ported system prompt plus its firm-memory, web-research, and Library-artifact tool declarations for a workbench chat agent",
"version": "0.0.1",
"license": "LGPL-2.1-or-later",
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc --noEmit",
"test": "bun test"
},
"dependencies": {
"@intx/agent": "0.3.0",
"@intx/types": "0.3.0",
"arktype": "catalog:"
},
"devDependencies": {
"@corbits/web-search-tools": "workspace:*",
"@types/bun": "catalog:",
"typescript": "catalog:"
}
}
94 changes: 94 additions & 0 deletions packages/scout-agent/src/artifact-client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { describe, expect, test } from "bun:test";
import {
createScoutArtifact,
listRecentScoutArtifacts,
} from "./artifact-client";

const CONFIG = {
hubArtifactsUrl: "https://hub.example",
sidecarToken: "sidecar-token",
runAddress: "run@example",
};

describe("createScoutArtifact", () => {
test("posts the input and returns the persisted id/version", async () => {
let capturedUrl = "";
let capturedInit: RequestInit | undefined;
const fetchImpl = (async (url: string, init?: RequestInit) => {
capturedUrl = url;
capturedInit = init;
return new Response(
JSON.stringify({ data: { id: "art_1", version: 1 } }),
{
status: 201,
},
);
}) as unknown as typeof fetch;

const result = await createScoutArtifact(
{ ...CONFIG, fetchImpl },
{ title: "Diligence note", kind: "text", content: "Body" },
);

expect(result).toEqual({ id: "art_1", version: 1 });
expect(capturedUrl).toBe("https://hub.example/api/workflow-artifacts/");
expect(capturedInit?.method).toBe("POST");
expect(JSON.parse(String(capturedInit?.body))).toEqual({
title: "Diligence note",
kind: "text",
content: "Body",
});
});

test("throws on a failed response", async () => {
const fetchImpl = (async () =>
new Response("nope", { status: 500 })) as unknown as typeof fetch;

await expect(
createScoutArtifact(
{ ...CONFIG, fetchImpl },
{ title: "x", kind: "text", content: "y" },
),
).rejects.toThrow(/Scout artifact create failed/);
});
});

describe("listRecentScoutArtifacts", () => {
test("returns the recent list", async () => {
const fetchImpl = (async () =>
new Response(
JSON.stringify({
data: [
{
id: "art_1",
title: "Note",
kind: "text",
createdAt: "2026-08-01T00:00:00Z",
},
],
}),
{ status: 200 },
)) as unknown as typeof fetch;

const items = await listRecentScoutArtifacts({ ...CONFIG, fetchImpl });
expect(items).toEqual([
{
id: "art_1",
title: "Note",
kind: "text",
createdAt: "2026-08-01T00:00:00Z",
},
]);
});

test("throws on a shape mismatch", async () => {
const fetchImpl = (async () =>
new Response(JSON.stringify({ data: "not an array" }), {
status: 200,
})) as unknown as typeof fetch;

await expect(
listRecentScoutArtifacts({ ...CONFIG, fetchImpl }),
).rejects.toThrow(/did not match the expected shape/);
});
});
114 changes: 114 additions & 0 deletions packages/scout-agent/src/artifact-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// A minimal client for the sanctioned workflow-artifacts HTTP surface
// (`@corbits/artifacts-hub`'s `createWorkflowArtifactRoutes`, CL-6000).
// Duplicated rather than imported, matching
// `workflows/last-30-days-research/src/artifact-client.ts`'s convention:
// this is Scout's own tool body, not a shared package, so it stays inside
// this package rather than adding a new cross-cutting dependency.
// Authenticates with the sidecar's own bearer token plus the run's own
// mailbox address (both already reach a workflow-process child's tool
// env), never a database handle.
import { type } from "arktype";

export interface ScoutArtifactClientConfig {
readonly hubArtifactsUrl: string;
readonly sidecarToken: string;
readonly runAddress: string;
/** Override for tests; defaults to the global `fetch`. */
readonly fetchImpl?: typeof fetch;
}

export type CreateScoutArtifactInput = {
readonly title: string;
readonly kind: string;
readonly content: string;
};

export type CreatedScoutArtifact = {
readonly id: string;
readonly version: number;
};

export type ScoutArtifactListItem = {
readonly id: string;
readonly title: string;
readonly kind: string;
readonly createdAt: string;
};

const CreatedScoutArtifactResponse = type({
data: { id: "string", version: "number" },
});

const ScoutArtifactListResponse = type({
data: type({
id: "string",
title: "string",
kind: "string",
createdAt: "string",
}).array(),
});

function authHeaders(
config: ScoutArtifactClientConfig,
): Record<string, string> {
return {
authorization: `Bearer ${config.sidecarToken}`,
"x-workflow-run-address": config.runAddress,
};
}

/** Persists one artifact. Throws on any transport, HTTP, or shape failure. */
export async function createScoutArtifact(
config: ScoutArtifactClientConfig,
input: CreateScoutArtifactInput,
): Promise<CreatedScoutArtifact> {
const doFetch = config.fetchImpl ?? fetch;
const response = await doFetch(
`${config.hubArtifactsUrl}/api/workflow-artifacts/`,
{
method: "POST",
headers: { ...authHeaders(config), "content-type": "application/json" },
body: JSON.stringify(input),
},
);
if (!response.ok) {
throw new Error(
`Scout artifact create failed: ${response.status} ${response.statusText}`,
);
}
const body: unknown = await response.json();
const parsed = CreatedScoutArtifactResponse(body);
if (parsed instanceof type.errors) {
throw new Error(
`Scout artifact create response did not match the expected shape: ${parsed.summary}`,
);
}
return parsed.data;
}

/** Lists the tenant's most recent artifacts. Throws on any transport, HTTP, or shape failure. */
export async function listRecentScoutArtifacts(
config: ScoutArtifactClientConfig,
params: { readonly limit?: number } = {},
): Promise<readonly ScoutArtifactListItem[]> {
const doFetch = config.fetchImpl ?? fetch;
const query =
params.limit !== undefined ? `?limit=${String(params.limit)}` : "";
const response = await doFetch(
`${config.hubArtifactsUrl}/api/workflow-artifacts/recent${query}`,
{ headers: authHeaders(config) },
);
if (!response.ok) {
throw new Error(
`Scout artifact list failed: ${response.status} ${response.statusText}`,
);
}
const body: unknown = await response.json();
const parsed = ScoutArtifactListResponse(body);
if (parsed instanceof type.errors) {
throw new Error(
`Scout artifact list response did not match the expected shape: ${parsed.summary}`,
);
}
return parsed.data;
}
Loading
Loading