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
21 changes: 16 additions & 5 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/jimmy-agent/LICENSE

Large diffs are not rendered by default.

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

Jimmy: a one-shot chat agent that searches Giphy and replies with a GIF.
Everything Jimmy needs — system prompt, tool declaration, and the Giphy
tool body — lives in this one package, per the portable-agent-package
convention (`defineAgent`'s "portable half," `corbitsdev/examples`).

## What's ported (from `scout/packages/jimmy`)

- The Giphy search HTTP client and response parsing (`gif-search-tool.ts`,
from `giphy-search.ts`).
- The system prompt and agent shape (`agent.ts`, from `index.ts`'s
`jimmyPackage`), trimmed to one tool call and one reply.

## What's deferred

- **Slack dispatch, block-kit rendering, and the `/gif` `/jimmy` slash
commands** — Slack-specific, out of scope for v0.1 per the owner.
- **The 4-up picker and the shuffle/cancel signal machine**
(`scout/workflows/jimmy`) — built for Slack's interactive buttons.
Workbench chat has no equivalent affordance yet, so Jimmy ships the
simple path only: one request, one GIF, no follow-up picker.
- **Wiring `gif_search`'s "giphy" credential handle to a real connector.**
This package resolves its credential through the same
`CredentialCapability.resolve("giphy")` seam every other tool package in
this repo uses (see `@corbits/web-search-tools`'s `tool.ts`), so once a
`giphy` connector exists it works with zero code changes here. As of
this package's introduction, that connector does not yet exist:
`packages/connections/src/registry.ts`'s `CONNECTOR_REGISTRY` has no
`giphy` entry, and none of its three credential-provider plugins
(`http`, `http-raw-authorization`, `http-x-api-key`) put the secret on
the query string the way Giphy's `/v1/gifs/search` endpoint requires —
a fourth plugin (mirroring `http-x-api-key-provider.ts`, injecting into
the URL's search params instead of a header) is the missing piece.
Until both land, `gif_search` always returns the "connect Giphy"
message, by design — never a silent failure.
- **Installing Jimmy as a mentionable chat agent.** This package exposes
`buildJimmyAgent`, an `AgentDefinition` ready to seed through
`packages/agent-directory`'s create path (the same path
`@corbits/code-review`'s reviewer agents install through) — that
seeding call is not wired in this change.

## Test plan run

`bun test` inside this package: a stubbed Giphy response produces a GIF
CDN URL, and an unbound/absent credential returns a "connect Giphy"
`isError` result rather than throwing or replying empty.
31 changes: 31 additions & 0 deletions packages/jimmy-agent/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"name": "@corbits/jimmy-agent",
"private": true,
"description": "Jimmy: a one-shot chat agent that searches Giphy and replies with a GIF",
"version": "0.0.1",
"license": "LGPL-2.1-or-later",
"type": "module",
"interchange": {
"credentials": [
{
"handle": "giphy"
}
]
},
"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": {
"@types/bun": "catalog:",
"typescript": "catalog:"
}
}
59 changes: 59 additions & 0 deletions packages/jimmy-agent/src/agent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// Jimmy's agent definition: the portable half described in
// `corbitsdev/examples`' agent-quickstart — a system prompt and a tool
// declaration, no credentials, no storage. A host binds this to a real
// Giphy credential and deploys it; this file only says what Jimmy *is*.
//
// Ported from `scout/packages/jimmy/src/index.ts`'s `jimmyPackage`: this
// keeps the one-shot "search Giphy, reply with a GIF" behavior and drops
// everything Slack-specific — the `/gif` slash command, the 4-up picker,
// and the shuffle/cancel signal machine (`scout/workflows/jimmy`). See
// this package's README for what that leaves deferred.
import type { AgentDefinition, InferencePreference } from "@intx/agent";
import type { ToolPackagePin } from "@intx/types/tool-packages";

import { GIF_SEARCH_TOOL } from "./gif-search-tool";

export const JIMMY_AGENT_ID = "jimmy";

/** This definition pins itself: the package that carries `gif_search` is this one. */
export const JIMMY_TOOL_PACKAGE_PINS: readonly ToolPackagePin[] = [
{ name: "@corbits/jimmy-agent", version: "0.0.1" },
];

export const JIMMY_SYSTEM_PROMPT =
"You are Jimmy. Someone mentions you in chat with a request for a GIF " +
`— call \`${GIF_SEARCH_TOOL}\` with their words as the search query and ` +
"reply with the GIF it finds.\n" +
"\n" +
"Call the tool exactly once per request, with a short, literal query " +
"drawn from what they asked for — do not embellish or add unrelated " +
"terms. Reply with the CDN URL the tool returns so the chat renders " +
"the GIF; do not describe the GIF instead of showing it, and never " +
"download, re-host, or link anywhere other than the returned URL.\n" +
"\n" +
"If the tool comes back telling you Giphy is not connected, say that " +
"plainly in one sentence and stop — never invent a GIF, a URL, or a " +
"description in its place. If the search finds nothing, say so and " +
"suggest the requester try different words.\n" +
"\n" +
"You are a one-shot responder, not a conversation: one request, one " +
"reply, no follow-up picker.";

export interface BuildJimmyAgentInput {
/** Provider/model preferences, in order; resolved at deploy time. */
readonly inferencePreferences: readonly InferencePreference[];
}

/** Builds Jimmy's `AgentDefinition` — the shape every installable agent
* in this catalog authors against (see `@corbits/code-review-workflow`). */
export function buildJimmyAgent(input: BuildJimmyAgentInput): AgentDefinition {
return {
id: JIMMY_AGENT_ID,
description: "Searches Giphy and replies with a GIF",
systemPrompt: JIMMY_SYSTEM_PROMPT,
toolFactories: [],
capabilities: [],
inference: { sources: input.inferencePreferences },
toolPackagePins: JIMMY_TOOL_PACKAGE_PINS,
} satisfies AgentDefinition;
}
123 changes: 123 additions & 0 deletions packages/jimmy-agent/src/gif-search-tool.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { expect, test } from "bun:test";
import type { ToolCall } from "@intx/types/runtime";
import type { CredentialCapability, MediatedCredential } from "@intx/types";

import { GIF_SEARCH_TOOL, gifSearchTool } from "./gif-search-tool";
import type { GifSearchEnv } from "./gif-search-tool";

const CALL: ToolCall = {
id: "call_1",
name: GIF_SEARCH_TOOL,
arguments: { query: "throw a party" },
};

/**
* Fake `credentials` capability, mirroring `@corbits/web-search-tools`'
* `tool.test.ts`: a bound secret resolves to a mediated fetch that
* delegates to `globalThis.fetch`; an unbound handle rejects the same way
* the real gate does when no credential is bound.
*/
function fakeCredentials(secret: string | undefined): CredentialCapability {
return {
resolve(handle: string): Promise<MediatedCredential> {
if (secret === undefined) {
return Promise.reject(
new Error(`no credential is bound to handle "${handle}"`),
);
}
return Promise.resolve({
kind: "http",
fetch: (input, init) => fetch(input as string | URL, init),
dispose: () => {},
});
},
};
}

function fakeEnv(credentials: CredentialCapability | undefined): GifSearchEnv {
return { credentials } as unknown as GifSearchEnv;
}

const GIPHY_RESPONSE = {
data: [
{
title: "party gif",
url: "https://giphy.com/gifs/party-abc123",
images: {
original: { url: "https://media.giphy.com/media/abc123/giphy.gif" },
},
},
],
meta: { status: 200, msg: "OK" },
};

test("declares the gif_search tool", () => {
const bundle = gifSearchTool(fakeEnv(fakeCredentials("key")));
expect(bundle.definitions.map((d) => d.name)).toEqual([GIF_SEARCH_TOOL]);
});

test("returns a gif CDN url for a stubbed Giphy search", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(JSON.stringify(GIPHY_RESPONSE), {
status: 200,
})) as unknown as typeof fetch;
try {
const bundle = gifSearchTool(fakeEnv(fakeCredentials("key")));
const result = await bundle.run(CALL, new AbortController().signal);
expect(result.isError).toBeUndefined();
expect(result.content).toContain(
"https://media.giphy.com/media/abc123/giphy.gif",
);
} finally {
globalThis.fetch = originalFetch;
}
});

test("surfaces a connect prompt, never a silent no-op, when Giphy is not connected", async () => {
const bundle = gifSearchTool(fakeEnv(fakeCredentials(undefined)));
const result = await bundle.run(CALL, new AbortController().signal);
expect(result.isError).toBe(true);
expect(result.content).toMatch(/connect giphy/i);
});

test("surfaces the same connect prompt when the step carries no credentials capability at all", async () => {
const bundle = gifSearchTool(fakeEnv(undefined));
const result = await bundle.run(CALL, new AbortController().signal);
expect(result.isError).toBe(true);
expect(result.content).toMatch(/connect giphy/i);
});

test("rejects a missing query without calling the network", async () => {
const originalFetch = globalThis.fetch;
let called = false;
globalThis.fetch = (async () => {
called = true;
return new Response("{}", { status: 200 });
}) as unknown as typeof fetch;
try {
const bundle = gifSearchTool(fakeEnv(fakeCredentials("key")));
const result = await bundle.run(
{ id: "call_2", name: GIF_SEARCH_TOOL, arguments: {} },
new AbortController().signal,
);
expect(called).toBe(false);
expect(result.isError).toBe(true);
expect(result.content).toContain("query");
} finally {
globalThis.fetch = originalFetch;
}
});

test("degrades to an error result (never throws) when Giphy rejects the key", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response("nope", { status: 401 })) as unknown as typeof fetch;
try {
const bundle = gifSearchTool(fakeEnv(fakeCredentials("key")));
const result = await bundle.run(CALL, new AbortController().signal);
expect(result.isError).toBe(true);
} finally {
globalThis.fetch = originalFetch;
}
});
Loading
Loading