@nestjs-adk/core 0.0.1 → 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +263 -10
- package/package.json +10 -1
package/README.md
CHANGED
|
@@ -1,28 +1,281 @@
|
|
|
1
1
|
# @nestjs-adk/core
|
|
2
2
|
|
|
3
|
-
**
|
|
3
|
+
**Build AI agents the NestJS way.**
|
|
4
|
+
|
|
5
|
+
NestJS developers already know how to build good software: classes, modules, providers and dependency injection. nestjs-adk brings AI agents into that same world. An agent is a class with a decorator. A tool is a provider. Everything is injected, validated and tested like the rest of your app.
|
|
6
|
+
|
|
7
|
+
This package is the framework itself. It gives you the decorators, the module, the run loop and all the contracts. To actually talk to an LLM you also install an engine, and the first supported engine is the Google ADK:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm i @nestjs-adk/core @nestjs-adk/google
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Your first agent
|
|
14
|
+
|
|
15
|
+
Start by registering the module once, in your root module. This is where you choose the engine and the default model:
|
|
4
16
|
|
|
5
17
|
```ts
|
|
18
|
+
import { AdkModule } from "@nestjs-adk/core";
|
|
19
|
+
import { GoogleAdkEngine } from "@nestjs-adk/google";
|
|
20
|
+
|
|
21
|
+
@Module({
|
|
22
|
+
imports: [
|
|
23
|
+
AdkModule.forRoot({
|
|
24
|
+
engine: GoogleAdkEngine,
|
|
25
|
+
defaultModel: "gemini-2.5-flash",
|
|
26
|
+
}),
|
|
27
|
+
],
|
|
28
|
+
providers: [SupportAgent, LookupOrderTool, OrdersService, ChatService],
|
|
29
|
+
})
|
|
30
|
+
export class AppModule {}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Now create the agent. It is a class that extends `AdkAgent` and is described by the `@Agent` decorator:
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
import { Agent, AdkAgent } from "@nestjs-adk/core";
|
|
37
|
+
|
|
6
38
|
@Agent({
|
|
7
39
|
name: "support_agent",
|
|
8
40
|
description: "Customer support.",
|
|
9
|
-
model: "gemini-2.5-flash",
|
|
10
41
|
prompt: "You are the store's support agent.",
|
|
11
42
|
tools: [LookupOrderTool],
|
|
12
43
|
})
|
|
13
44
|
export class SupportAgent extends AdkAgent {}
|
|
45
|
+
```
|
|
14
46
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
47
|
+
Notice that the agent went into `providers` like any other class. There is no special registration step. If you forget to register a tool, a prompt class or a sub-agent, the app fails at startup with an error that points at the missing class, so configuration mistakes never reach runtime.
|
|
48
|
+
|
|
49
|
+
To use the agent, inject it. The instance itself is the handle:
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
@Injectable()
|
|
53
|
+
export class ChatService {
|
|
54
|
+
constructor(private readonly support: SupportAgent) {}
|
|
55
|
+
|
|
56
|
+
async answer(sessionId: string, message: string) {
|
|
57
|
+
const { text } = await this.support.ask({ sessionId, message });
|
|
58
|
+
return text;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
18
61
|
```
|
|
19
62
|
|
|
20
|
-
|
|
63
|
+
`ask()` runs the agent and returns the final result. `stream()` gives you the events one by one while the agent works. Later in this document you will also meet `approve()` and `reject()`, used for human approval.
|
|
21
64
|
|
|
22
|
-
|
|
65
|
+
That is the whole mental model: configure the module once, register classes as providers, inject the agent and call it.
|
|
23
66
|
|
|
24
|
-
|
|
25
|
-
|
|
67
|
+
## Tools
|
|
68
|
+
|
|
69
|
+
A tool is something the model can decide to call. In nestjs-adk a shared tool is a class that extends `AdkTool`. The Zod schema plays two roles at the same time: it tells the model what arguments exist, and it types the input for you:
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
import { Tool, AdkTool, type ToolContext } from "@nestjs-adk/core";
|
|
73
|
+
import { z } from "zod";
|
|
74
|
+
|
|
75
|
+
const schema = z.object({ city: z.string().describe("City name") });
|
|
76
|
+
|
|
77
|
+
@Tool({ name: "get_weather", description: "Current weather.", schema })
|
|
78
|
+
export class GetWeatherTool extends AdkTool<typeof schema> {
|
|
79
|
+
constructor(private readonly weather: WeatherService) {
|
|
80
|
+
super();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
execute(input: z.infer<typeof schema>, ctx: ToolContext) {
|
|
84
|
+
return this.weather.fetch(input.city);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
26
87
|
```
|
|
27
88
|
|
|
28
|
-
|
|
89
|
+
The tool is a normal provider, so it can inject services, repositories or anything else. Whatever `execute` returns goes back to the model, as long as it is serializable.
|
|
90
|
+
|
|
91
|
+
The `execute` method receives two very different things. The `input` comes from the model: it decided the values based on the schema. The `ctx` comes from your application: it carries `userId`, custom `attributes` and the session `state` that you passed to `ask()`. This separation matters for security. Sensitive data like a tenant id should never be part of the schema, because the model could invent it. Pass it through `ctx` instead, where the model cannot touch it.
|
|
92
|
+
|
|
93
|
+
When a tool belongs to a single agent, you can skip the class and declare it as a method on the agent itself:
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
@Agent({ name: "support_agent", description: "Customer support.", prompt: "..." })
|
|
97
|
+
export class SupportAgent extends AdkAgent {
|
|
98
|
+
constructor(private readonly orders: OrdersService) {
|
|
99
|
+
super();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
@Tool({ description: "Looks up an order by id.", schema: orderSchema })
|
|
103
|
+
lookupOrder(input: z.infer<typeof orderSchema>) {
|
|
104
|
+
return this.orders.find(input.orderId);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Skills
|
|
110
|
+
|
|
111
|
+
Skills are blocks of domain knowledge written as text. They exist so your prompt does not grow into one giant string. A skill can be a class that extends `AdkSkill` with the `@Skill` decorator, or a method on the agent.
|
|
112
|
+
|
|
113
|
+
Each skill has a mode. With `mode: "always"` the content is included in the instruction on every run. The default mode is on demand: the agent only sees a catalog with the skill names and descriptions, plus a `load_skill` tool it can call when it decides it needs the full content. This keeps the context small while still making the knowledge available.
|
|
114
|
+
|
|
115
|
+
## Prompts
|
|
116
|
+
|
|
117
|
+
There are two ways to give an agent its instruction, and they are separate fields so the intent is always clear.
|
|
118
|
+
|
|
119
|
+
The first way is directly on the decorator. Use `prompt` for literal text and `promptFile` for a markdown file:
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
@Agent({ name: "support", prompt: "You are the store's support agent." })
|
|
123
|
+
|
|
124
|
+
@Agent({ name: "support", promptFile: "agents/support/main.prompt.md" })
|
|
125
|
+
|
|
126
|
+
@Agent({ name: "support", promptFile: "./prompts/main.prompt.md" })
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
A plain `promptFile` path is resolved from the prompts directory that you configure in `forRoot({ prompts: { dir } })`. A path that starts with `./` is resolved relative to the agent's own file. Files are read once and cached in memory.
|
|
130
|
+
|
|
131
|
+
The second way is a builder class, for prompts that need logic or data. Extend `AdkPrompt`, register it as a provider and point the `prompt` field at the class:
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
@Injectable()
|
|
135
|
+
class SupportPrompt extends AdkPrompt {
|
|
136
|
+
constructor(private readonly config: SupportConfig) {
|
|
137
|
+
super();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
build(ctx: PromptContext) {
|
|
141
|
+
return this.fromFile("agents/support/main.prompt.md", {
|
|
142
|
+
tone: this.config.tone,
|
|
143
|
+
plan: ctx.state.get("plan"),
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
@Agent({ name: "support", prompt: SupportPrompt })
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
The builder has full dependency injection and receives the run context, so it can read the state and the attributes you passed to `ask()`. The `fromFile` helper reads a cached template and fills `{{var}}` placeholders.
|
|
152
|
+
|
|
153
|
+
Setting both `prompt` and `promptFile` on the same agent is an error at startup. One agent, one source of instruction.
|
|
154
|
+
|
|
155
|
+
The final instruction is always composed in the same order: the prompt, then the `always` skills, then the on demand catalog. Because the order is stable, the prefix of your requests stays identical between runs, which lets the provider cache it.
|
|
156
|
+
|
|
157
|
+
## Models
|
|
158
|
+
|
|
159
|
+
The `model` field on `@Agent` and the `defaultModel` on `forRoot` accept a string or a model spec class. Specs are small objects that only carry configuration. The engine turns them into real clients:
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
model: "gemini-2.5-flash"
|
|
163
|
+
|
|
164
|
+
model: new Gemini("gemini-2.5-flash", { labels, cache: { content }, config })
|
|
165
|
+
|
|
166
|
+
model: new OpenAiLike("gpt-4o-mini", { baseUrl, apiKeyEnv })
|
|
167
|
+
|
|
168
|
+
model: new ModelRouter({
|
|
169
|
+
targets: {
|
|
170
|
+
primary: new Gemini("gemini-2.5-flash"),
|
|
171
|
+
fallback: new OpenAiLike("gpt-4o-mini", { baseUrl: "https://openrouter.ai/api/v1" }),
|
|
172
|
+
},
|
|
173
|
+
})
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
`OpenAiLike` covers every provider that speaks the OpenAI API, which includes OpenAI itself, OpenRouter, Ollama and many others. `Gemini` lives in `@nestjs-adk/google` and adds Vertex AI options, billing labels and explicit content caching.
|
|
177
|
+
|
|
178
|
+
`ModelRouter` gives you failover in one line. When the current target fails before the first chunk of the response, for example with a 429, the router moves to the next target in the declared order. Every switch is reported as a `model_rerouted` event, so failovers are never silent. If you use a router as your `defaultModel`, you get global failover for the whole app.
|
|
179
|
+
|
|
180
|
+
## Sessions
|
|
181
|
+
|
|
182
|
+
Pass a `sessionId` to `ask()` and the conversation becomes persistent. The agent remembers previous turns because the framework stores every event and replays the history into the model's context on each run. Without a `sessionId` the session is ephemeral and nothing is kept.
|
|
183
|
+
|
|
184
|
+
Storage goes through the `SessionStore` contract. The default implementation keeps everything in memory, which is perfect for development. For production you implement the contract with your own database and pass the class to `forRoot({ session })`. The store is the single source of truth: engines read from it and write to it, and never keep a private copy of the history.
|
|
185
|
+
|
|
186
|
+
## Keeping the context small
|
|
187
|
+
|
|
188
|
+
Long conversations and big tool results eat your context window. The framework handles both cases for you.
|
|
189
|
+
|
|
190
|
+
When a tool returns a very large result, above 20 thousand characters, the framework stores the full content as an artifact and gives the model a short summary plus a `read_artifact` tool. The model can read the full content when it really needs it. You can turn this off for a specific tool with `offload: false`.
|
|
191
|
+
|
|
192
|
+
For long histories there is compaction. Configure a policy and old turns get summarized by an LLM when the history passes a token threshold:
|
|
193
|
+
|
|
194
|
+
```ts
|
|
195
|
+
context: contextPolicy({
|
|
196
|
+
compaction: { maxTokens: 50_000, keepRecent: 5 },
|
|
197
|
+
})
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
You can set the policy globally in `forRoot` and override it per agent.
|
|
201
|
+
|
|
202
|
+
## Human approval
|
|
203
|
+
|
|
204
|
+
Some actions should not run without a person saying yes. Mark the tool:
|
|
205
|
+
|
|
206
|
+
```ts
|
|
207
|
+
@Tool({ name: "refund", description: "Refunds an order.", schema, requiresApproval: true })
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
`requiresApproval` also accepts a function of the input and the context, so you can require approval only above a value, for example.
|
|
211
|
+
|
|
212
|
+
When the model calls a tool that requires approval, the tool does not execute. The run pauses and returns `status: "pending_approval"` with the pending call id. Your application shows this to a human, and then:
|
|
213
|
+
|
|
214
|
+
```ts
|
|
215
|
+
await agent.approve({ sessionId, callId }); // executes the tool and resumes the run
|
|
216
|
+
await agent.reject({ sessionId, callId, reason }); // skips the tool and tells the model why
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
Both calls return a normal run result, so the conversation continues naturally after the decision.
|
|
220
|
+
|
|
221
|
+
## Structured output
|
|
222
|
+
|
|
223
|
+
When you need data instead of prose, declare an output schema:
|
|
224
|
+
|
|
225
|
+
```ts
|
|
226
|
+
@Agent({ name: "reporter", description: "Builds reports.", output: reportSchema, outputKey: "report" })
|
|
227
|
+
class ReporterAgent extends AdkAgent<typeof reportSchema> {}
|
|
228
|
+
|
|
229
|
+
const run = await reporter.ask({ message });
|
|
230
|
+
run.output; // typed and validated
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
The result is parsed and validated with the schema. If the model produces something that does not match, you get an `OutputValidationError` instead of silently wrong data. The optional `outputKey` also writes the validated output into the session state, which is useful to pass data between agents in a pipeline.
|
|
234
|
+
|
|
235
|
+
## Sub-agents and workflows
|
|
236
|
+
|
|
237
|
+
An agent can delegate to other agents. With `subAgents: [OtherAgent]` the model itself decides when to transfer the conversation. When you want deterministic control instead, declare a workflow:
|
|
238
|
+
|
|
239
|
+
```ts
|
|
240
|
+
@WorkflowAgent({ name: "etl", mode: "sequential", agents: [ExtractAgent, SummarizeAgent] })
|
|
241
|
+
class EtlWorkflow extends AdkWorkflow {}
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
Modes are `sequential`, `parallel` and `loop`. A workflow is also an agent: you inject the class and call `ask()` or `stream()` on the instance, exactly like before.
|
|
245
|
+
|
|
246
|
+
## Streaming, events and errors
|
|
247
|
+
|
|
248
|
+
`stream()` yields a normalized event loop: `run_start`, `tool_call`, `tool_result`, `llm_response`, `model_rerouted`, `approval_required` and `final`. Every event carries a `raw` field with the original payload from the provider, so no information is lost. `ask()` consumes the same loop and aggregates it into a `RunResult` with `text`, `usage`, `events`, `status` and, when declared, `output`.
|
|
249
|
+
|
|
250
|
+
Errors are not events. They throw as typed classes that extend `AdkError` and carry a `code`. Configuration problems throw at startup and point at the class that caused them. Runtime problems throw classes like `AiEmptyResponseError`, `OutputValidationError`, `ToolExecutionError` and `ModelsExhaustedError`, so you can catch exactly what you care about.
|
|
251
|
+
|
|
252
|
+
## Logs
|
|
253
|
+
|
|
254
|
+
Turn on run logs in the module:
|
|
255
|
+
|
|
256
|
+
```ts
|
|
257
|
+
AdkModule.forRoot({ engine: GoogleAdkEngine, logging: "debug" })
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
Logs go through the normal Nest `Logger` with the context `Adk:<agent_name>`. Levels are cumulative. `"info"` (or `true`) logs the start and the end of each run with duration and token usage. `"debug"` adds tool calls and tool results. `"verbose"` adds intermediate model responses and stops truncating payloads. Reroutes and approval pauses are always logged as warnings.
|
|
261
|
+
|
|
262
|
+
```
|
|
263
|
+
run start session=smoke-1 user=u1 message=What's the status of my order 123?
|
|
264
|
+
tool call lookup_order args={"orderId":"123"}
|
|
265
|
+
tool result lookup_order result={"id":"123","status":"shipped"}
|
|
266
|
+
run done in 1389ms text=Your order 123 has shipped. | tokens in=772 out=41 total=813
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
Token usage is also available programmatically on every result as `run.usage`.
|
|
270
|
+
|
|
271
|
+
## Embeddings
|
|
272
|
+
|
|
273
|
+
The core ships an `Embedder` contract with no default implementation, so you bring the provider you prefer. Configure it once with `forRoot({ embedder })` and inject `Embedder` wherever you need vectors, for semantic search or deduplication. The `Similarity` provider offers cosine similarity, and the testing package uses the same embedder for semantic assertions.
|
|
274
|
+
|
|
275
|
+
## Testing
|
|
276
|
+
|
|
277
|
+
Testing deserves its own package. [`@nestjs-adk/testing`](https://www.npmjs.com/package/@nestjs-adk/testing) gives you a scripted fake LLM, stackable mocks over the real agent instance, Vitest matchers and an LLM-as-judge helper. Your test setup stays plain `@nestjs/testing`.
|
|
278
|
+
|
|
279
|
+
## Learn more
|
|
280
|
+
|
|
281
|
+
The full project, with a working playground app and real AI smoke tests, lives at [github.com/gabrieljsilva/nestjs-adk](https://github.com/gabrieljsilva/nestjs-adk).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nestjs-adk/core",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.3",
|
|
4
4
|
"description": "NestJS-native agent framework: decorators, DI and abstract contracts over pluggable AI agent engines (Google ADK first).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -31,5 +31,14 @@
|
|
|
31
31
|
},
|
|
32
32
|
"publishConfig": {
|
|
33
33
|
"access": "public"
|
|
34
|
+
},
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "git+https://github.com/gabrieljsilva/nestjs-adk.git",
|
|
38
|
+
"directory": "packages/core"
|
|
39
|
+
},
|
|
40
|
+
"homepage": "https://github.com/gabrieljsilva/nestjs-adk#readme",
|
|
41
|
+
"bugs": {
|
|
42
|
+
"url": "https://github.com/gabrieljsilva/nestjs-adk/issues"
|
|
34
43
|
}
|
|
35
44
|
}
|