@harpua/agent-tools
v0.8.0
Published
Framework-agnostic prebuilt LangChain tools for agents (works with any LangChain/LangGraph app)
Readme
@harpua/agent-tools
Framework-agnostic prebuilt LangChain
tools for agents. Each tool is a plain tool() instance, so it drops into any
LangChain / LangGraph TypeScript app — a ToolNode, createReactAgent,
bindTools, or your own executor. Peer dependencies are @langchain/core and zod; runtime dependencies are ml-distance (pure JS, cosine similarity for the knowledge family) and node-html-markdown (HTML → markdown conversion for fetch_url); still no NestJS, no LangGraph runtime.
Table of Contents
Install
pnpm add @harpua/agent-tools
# peers you almost certainly already have:
pnpm add @langchain/core zodTools
thinkTool(options?)
The Anthropic-style think tool: a no-op scratchpad the model calls to record reasoning between tool calls. The handler returns an empty string — nothing executes; the thought is simply logged for the model's own benefit. Useful before an irreversible action, when policies conflict, or when a tool result is surprising.
import { thinkTool } from "@harpua/agent-tools";
import { ToolNode } from "@langchain/langgraph/prebuilt";
// Vanilla LangGraph: mount it in a ToolNode like any other tool.
const toolNode = new ToolNode([thinkTool()]);Tune the when-to-think guidance per domain by overriding the description:
const think = thinkTool({
description:
"Think before cancelling or refunding: confirm the order state and that " +
"the customer's request matches policy.",
});options is validated with zod ({ description?: string }, unknown keys
rejected). The tool's input schema is z.object({ thought: z.string() }).
fileExplorationTools(options)
A family of read-only, sandboxed, context-safe tools for navigating a
codebase — search_files, read_lines, and file_stats. Every path is confined
to options.root (.. traversal and symlink escapes are refused), every result
is bounded (match / byte / page / entry caps with explicit truncation markers so
no single call floods the model's context), and nothing ever writes. The tool
descriptions teach the workflow: size things up with file_stats, locate lines
with search_files, then page just those with read_lines.
Secrets stay unreadable. search_files never searches hidden files (dotfiles
and dot-directories), and no glob overrides that — so a search can't be used to
read .env. read_lines and file_stats refuse a curated set of secret paths
(.env, .ssh/, private keys, and similar; configurable via
blockedSecretPatterns), checked on the resolved real path so a symlink or ..
can't smuggle one through. Non-secret dotfiles (.github/, .vscode/) and
.env.example-style templates stay readable.
import { fileExplorationTools } from "@harpua/agent-tools";
import { ToolNode } from "@langchain/langgraph/prebuilt";
// One bundle, shared sandbox + caps. Defaults shown are optional.
const tools = fileExplorationTools({
root: process.cwd(),
pageLines: 200, // read_lines page size
maxMatches: 50, // search_files match cap
maxOutputBytes: 16_384, // byte cap on streamed output
maxFileBytes: 2_000_000, // read_lines size ceiling
});
const toolNode = new ToolNode(tools);search_files{ pattern, glob? }— regex search via ripgrep (rgmust be installed; the tool returns an install hint if it isn't). Respects ignore files, skips hidden files, and caps output. On an empty result it says why — genuinely absent, glob matched nothing, or the matches were hidden or ignored — rather than a bare "No matches." that an agent could misread as "not there" about files it never opened.read_lines{ path, start? }— one line-numbered page of a text file with afile — lines A–B of TOTALheader and the nextstart=when more remain. Refuses binary files, oversize files, and secret paths.file_stats{ path? }— line count / byte size / binary flag for a file, or a bounded per-file listing for a directory (omitpathfor the root). Refuses to stat a secret path.
options is validated with zod (root required; every cap is a positive
integer with a default; unknown keys rejected). The individual factories
(searchFilesTool, readLinesTool, fileStatsTool) are exported too — the
bundle is the primary API since the three share one sandbox configuration.
Web research — web_search + fetch_url
Search the web through a SearXNG instance and save pages locally as searchable markdown:
web_search— queries{baseUrl}/search?format=jsonand returns a numbered list of results (title, URL, snippet). The instance must have the JSON format enabled insettings.yml(search: formats: [html, json]).fetch_url— fetches an http(s) page, converts HTML to markdown withnode-html-markdown(plain text is saved as-is; PDFs and other binary types are politely refused), and writes it tosaveDirwithurl/title/fetchedfrontmatter. Re-fetching a URL refreshes its file.saveDircan be a function of the run config for per-thread dirs.
Pair the family with fileExplorationTools jailed to the same directory so
the agent can search what it saved:
import fs from "node:fs";
import { webResearchTools, fileExplorationTools } from "@harpua/agent-tools";
import { ToolNode } from "@langchain/langgraph/prebuilt";
const sources = "./sources";
fs.mkdirSync(sources, { recursive: true });
const toolNode = new ToolNode([
...webResearchTools({ baseUrl: "http://localhost:8080", saveDir: sources }),
...fileExplorationTools({ root: sources }),
]);Both tools return every failure (network, HTTP status, content type, size
cap, filesystem) as a friendly string — they never throw mid-graph. The
model chooses the URLs, so fetch_url refuses loopback/private/link-local
addresses by default (including redirects that land on one) — pass
allowPrivate: true to reach a service on your own machine or LAN. That's a
safety net, not a boundary: it inspects the literal hostname only (no DNS
resolution), so real egress control belongs at the deployment layer, and
publicly-deployed apps should still gate fetch_url
(e.g. requireApproval() from @harpua/langgraph) or front it with an
allowlist.
fetchPdfTool(options) — opt-in PDF fetching
fetch_pdf is the same fetch → save → explore loop as fetch_url, but for
PDFs: it fetches an http(s) URL, verifies the response is application/pdf,
extracts the text, and writes it to saveDir as markdown with the same
url / title / fetched frontmatter — so a fetched PDF becomes searchable
by fileExplorationTools exactly like a fetched page. It inherits every
guard from fetch_url (http(s)-only, private/loopback refusal including
redirects) and likewise never throws — bad schemes, non-PDF content types,
oversize bodies, extraction failures, and filesystem errors all come back as
friendly strings. Sizing is its own, though: fetch_pdf checks the declared
- actual response size against its own 16 MB cap, independent of
fetch_url's 2 MB (HTML/text-oriented) cap — real-world PDFs regularly exceed the latter. On success it reports the extracted text's size as chars/pages (a PDF's extracted text is often one long run with no newlines, so a line count wouldn't be meaningful).
It is opt-in: fetch_pdf is exported on its own and is not part of
the webResearchTools() bundle — add it explicitly. Text extraction uses
unpdf, an optional peer dependency you
install only if you want PDF support:
pnpm add unpdfimport fs from "node:fs";
import {
webResearchTools,
fetchPdfTool,
fileExplorationTools,
} from "@harpua/agent-tools";
import { ToolNode } from "@langchain/langgraph/prebuilt";
const sources = "./sources";
fs.mkdirSync(sources, { recursive: true });
const toolNode = new ToolNode([
...webResearchTools({ baseUrl: "http://localhost:8080", saveDir: sources }),
fetchPdfTool({ saveDir: sources }), // opt-in; needs `unpdf` installed
...fileExplorationTools({ root: sources }),
]);options mirrors fetchUrlTool's (saveDir required; maxResponseBytes,
timeoutMs, allowPrivate, fetchFn, now optional). If unpdf isn't
installed the tool returns an install hint instead of throwing, so the rest of
your graph keeps working without it.
Knowledge — search_knowledge
Semantic-ish retrieval over a directory of markdown — the same sources
directory fetch_url and fetch_pdf fill. Chunks are heading-aware with
true line spans; vectors live in a hidden sidecar (.knowledge/index.json)
that refreshes lazily on every search (only new/changed files re-embed).
Results carry file.md:start-end references that feed read_lines.
Keyless by default: the built-in MockEmbeddings is a deterministic
lexical stand-in (word overlap, not meaning). For real semantic search,
pass any LangChain embeddings instance:
import { searchKnowledgeTool, webResearchTools, fileExplorationTools } from "@harpua/agent-tools";
import { OpenAIEmbeddings } from "@langchain/openai";
import { ToolNode } from "@langchain/langgraph/prebuilt";
import fs from "node:fs";
const sources = "./sources";
fs.mkdirSync(sources, { recursive: true });
const embeddings = new OpenAIEmbeddings({
model: "nomic-ai/nomic-embed-text-v1",
apiKey: process.env.OPENROUTER_API_KEY,
configuration: { baseURL: "https://openrouter.ai/api/v1" },
});
const toolNode = new ToolNode([
...webResearchTools({ baseUrl: "http://localhost:8080", saveDir: sources }),
...fileExplorationTools({ root: sources }),
searchKnowledgeTool({ root: sources, embeddings }),
]);Switching embedders (or from the mock to a real one) is detected via a
fingerprint — constructor name, model when the embedder exposes one, vector
dimension, and chunk size — and triggers a clean re-index — vector spaces
never mix. If you swap between two embedders the fingerprint can't tell
apart (same class, no distinguishing model), just delete .knowledge/:
it's only a cache; markdown stays the source of truth. Runtime dependency
note: this family adds ml-distance (pure JS) for cosine similarity — see
the intro for the package's full runtime dependency list.
Two backends, two names: search_knowledge + search_memory
The tool's name and description are overridable, so an app can mount the
fetched-sources corpus and a remembered-excerpts store as two distinctly
named tools and let the agent pick a backend explicitly. With a bring-your-own
store the corpus root is not required, and the tool's failure/empty
messages carry whichever name you gave it. Pair the store with remember
(the write half) and point its searchToolName at the renamed reader so its
guidance stays coherent:
import {
searchKnowledgeTool,
rememberTool,
InMemoryVectorStore,
} from "@harpua/agent-tools";
const store = new InMemoryVectorStore({ topK: 5 });
const toolNode = new ToolNode([
// fetched pages / PDFs / notes on disk:
searchKnowledgeTool({ root: sources, embeddings }),
// excerpts the agent deliberately saved:
searchKnowledgeTool({
store,
embeddings,
name: "search_memory",
description:
"Search excerpts you previously chose to remember. Prefer this for " +
"facts you saved yourself; use search_knowledge for the full sources.",
}),
rememberTool({ store, embeddings, searchToolName: "search_memory" }),
]);Bulk ingest — chunking controls on ingest()
ingest(documents, options) is the source-agnostic write path behind
syncCorpus and remember: chunk → sanitize → junk-filter → embed
(batched) → upsert (batched). Beyond embeddings, store, and
maxChunkChars it takes four knobs, all optional:
sanitize?: (text: string) => string— applied to each chunk's text before everything else (the junk floor, the embedder, and the stored text all see its output). Default: strip C0/C1 control characters, keeping\tand\n. Scraped PDFs routinely carry0x01–0x05/0x0E; those bytes are pure embedding noise and have broken the postgres wire protocol on insert. Pass your own function to add trimming/normalization, or(t) => tto opt out.minAlnumChars?: number(default0= off) — drop chunks whose alphanumeric character count (letters + digits, not raw length) is below the floor. Calibration: a sparse-but-real table row like| 200-400mA | 5V |carries 10 alnum chars and survives a floor of 8, while---separators and heading-only stubs carry 0–6 and only poison the vector space. Field data: a floor of 8 took junk chunks in a scraped hardware corpus from 11% to 0.embedHeadingTrail?: boolean(defaultfalse) — when true, the text sent to the embedder becomes"<headingTrail joined with ' > '>: <chunk text>"(e.g."Power > Limits: The regulator caps at 5V."); chunks with no trail embed as raw text. The stored text stays the raw chunk text either way. The default keeps the legacy embedding input (heading trail + body joined by newlines), so existing indexes stay valid.batchSize?: number(default64) — max records perembeddings.embedDocumentscall and perstore.upsertcall. A 1.9MB document at small chunk sizes is ~6k chunks; one giant embed call plus one giant insert has crashed node natively in the field.
Options are zod-validated at call time — a negative floor, fractional batch size, non-function sanitizer, or unknown key throws before any embedding work.
Every stored record also carries metadata.chunkIndex: sequential per
document and dense after the junk filter (0, 1, 2, … with no gaps).
That's the handle for window expansion at retrieval time:
Two operational notes. Upserts are batched: a store failure mid-ingest can leave earlier batches committed (embedding failures still happen before any store write). And id-less documents are keyed by a content hash of the raw text — re-ingesting the same text with different chunking options (floor, cap) writes under new positions instead of replacing, stranding the old records; give documents explicit
ids when options may evolve.
Window-expansion retrieval (recipe)
Chunks sized for embedding precision are often too small to answer from.
The fix is consumer-side: store chunkIndex in a real chunk_index column
in your VectorStore adapter, then widen each hit into its neighborhood at
query time —
- Vector-search as usual; collect the top-K hits.
- For each hit, fetch its neighbors with one indexed range query per
window:
WHERE document_key = $1 AND chunk_index BETWEEN $2 - w AND $2 + w(index(document_key, chunk_index)). - Within a document, stitch consecutive runs of chunk indexes back into
passages (indexes are dense, so
n, n+1, n+2means adjacent text). - Merge overlapping windows from multiple hits in the same document into one passage; the passage's score is its best hit's score.
Two warnings from the field, both load-bearing:
- Coerce
chunk_indexto a number at the query boundary. Several pg drivers return integer columns as strings, and"5941" + 2in JS CONCATENATES to"59412"— a window of[5941, 59412]swallows an entire document (a live result once weighed 1.2MB).Number(row.chunk_index)before any arithmetic. - Hard-cap every stitched passage (~8KB is a sane ceiling) so no future bug — a bad window bound, a runaway merge — can blow a model context through the retrieval path.
Runtime skills — use_skill + read_skill_file
Skills for the app's own agent at runtime — the counterpart to linking
skills into .claude/skills for a developer's coding agent. The agent sees a
menu of procedures, loads one when it applies, and reads its reference files
on demand at a stated cost.
import { SkillRegistry, useSkillTool, readSkillFileTool } from "@harpua/agent-tools";
const registry = new SkillRegistry(".agents/skills"); // <skill>/SKILL.md per skill
const tools = [useSkillTool({ registry }), readSkillFileTool({ registry })];SkillRegistry(dir, { onWarn? })scans<skill>/SKILL.mdfiles (frontmattername+description;namemust match the directory). Malformed, symlinked, or oversized entries are skipped with a warning — never a crash.rescan()picks up skills installed mid-session and reports{ count, names, skipped, changed }, wherechangedcompares rendered menu bytes (atruemeans the next call's system prompt moves and the provider's prefix cache resets).use_skill(name)returns the skill body as a tool result — a skill is a procedure that must persist through the tool loop, and an ephemeral prompt injection hands the model a checklist that vanishes before cycle 2. References are LISTED with line counts, never read. Unknown name → the menu.read_skill_file(skill, path, startLine?, endLine?)reads out of a per-skill jail (the skill's own directory is the sandbox root, so../other-skill/…cannot resolve, symlinks included) with hard caps per read — progressive disclosure enforced structurally, not by asking nicely.renderSkillMenu(registry.menu())renders the system-prompt TOC (""when empty). Putting the LIVE menu in the system prompt is a ~12-linewrapModelCallmiddleware in your framework — with@harpua/langgraph, follow the "Composing the system prompt" recipe in itsagents-and-middlewareskill reference (append to the leading SystemMessage; byte-stable output keeps the prompt cache warm).
Using with @harpua/langgraph
@harpua/langgraph accepts these tools directly in a graph's
tools array, mixed freely with its own @LangGraphTool provider classes:
import { LangGraph, defineEdges, START, TOOLS, END } from "@harpua/langgraph";
import { thinkTool } from "@harpua/agent-tools";
@LangGraph({ name: "agent", state: AgentState, tools: [OrderTools, thinkTool()] })
export class AgentGraph {
edges = defineEdges<AgentState>([
{ from: START, to: CallModel },
{ from: CallModel, to: route<AgentState>(shouldContinue, [TOOLS, END]) },
{ from: TOOLS, to: CallModel },
]);
}The raw tool is mounted into the same ToolNode and traced with a
langgraph.tool think span like any DI-bound tool. The file-exploration bundle
composes the same way — spread ...fileExplorationTools({ root }) into a graph's
tools array and each tool is mounted and traced like any other raw tool.
