@charming_groot/agent 0.1.0

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.
Files changed (57) hide show
  1. package/dist/agent-loop.d.ts +46 -0
  2. package/dist/agent-loop.d.ts.map +1 -0
  3. package/dist/agent-loop.js +139 -0
  4. package/dist/agent-loop.js.map +1 -0
  5. package/dist/index.d.ts +15 -0
  6. package/dist/index.d.ts.map +1 -0
  7. package/dist/index.js +9 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/message-manager.d.ts +46 -0
  10. package/dist/message-manager.d.ts.map +1 -0
  11. package/dist/message-manager.js +152 -0
  12. package/dist/message-manager.js.map +1 -0
  13. package/dist/permission.d.ts +37 -0
  14. package/dist/permission.d.ts.map +1 -0
  15. package/dist/permission.js +62 -0
  16. package/dist/permission.js.map +1 -0
  17. package/dist/session-manager.d.ts +31 -0
  18. package/dist/session-manager.d.ts.map +1 -0
  19. package/dist/session-manager.js +101 -0
  20. package/dist/session-manager.js.map +1 -0
  21. package/dist/skill-tool.d.ts +38 -0
  22. package/dist/skill-tool.d.ts.map +1 -0
  23. package/dist/skill-tool.js +112 -0
  24. package/dist/skill-tool.js.map +1 -0
  25. package/dist/sub-agent-tool.d.ts +39 -0
  26. package/dist/sub-agent-tool.d.ts.map +1 -0
  27. package/dist/sub-agent-tool.js +80 -0
  28. package/dist/sub-agent-tool.js.map +1 -0
  29. package/dist/token-counter.d.ts +16 -0
  30. package/dist/token-counter.d.ts.map +1 -0
  31. package/dist/token-counter.js +69 -0
  32. package/dist/token-counter.js.map +1 -0
  33. package/dist/tool-dispatcher.d.ts +15 -0
  34. package/dist/tool-dispatcher.d.ts.map +1 -0
  35. package/dist/tool-dispatcher.js +94 -0
  36. package/dist/tool-dispatcher.js.map +1 -0
  37. package/package.json +34 -0
  38. package/src/agent-loop.ts +210 -0
  39. package/src/index.ts +19 -0
  40. package/src/message-manager.ts +184 -0
  41. package/src/permission.ts +104 -0
  42. package/src/session-manager.ts +121 -0
  43. package/src/skill-tool.ts +155 -0
  44. package/src/sub-agent-tool.ts +122 -0
  45. package/src/token-counter.ts +79 -0
  46. package/src/tool-dispatcher.ts +124 -0
  47. package/tests/agent-loop.test.ts +372 -0
  48. package/tests/message-manager-new.test.ts +204 -0
  49. package/tests/message-manager.test.ts +195 -0
  50. package/tests/permission.test.ts +148 -0
  51. package/tests/session-manager.test.ts +106 -0
  52. package/tests/skill-tool.test.ts +119 -0
  53. package/tests/sub-agent-tool.test.ts +198 -0
  54. package/tests/token-counter.test.ts +77 -0
  55. package/tests/tool-dispatcher.test.ts +181 -0
  56. package/tsconfig.json +9 -0
  57. package/vitest.config.ts +17 -0
@@ -0,0 +1,155 @@
1
+ import type {
2
+ ITool,
3
+ ToolDescription,
4
+ ToolResult,
5
+ JsonObject,
6
+ AgentLogger,
7
+ } from '@charming_groot/core';
8
+ import type { RunContext } from '@charming_groot/core';
9
+ import { createChildLogger } from '@charming_groot/core';
10
+
11
+ /** Minimal skill shape — avoids hard dependency on @core/types */
12
+ export interface SkillEntry {
13
+ readonly name: string;
14
+ readonly description: string;
15
+ readonly tools: readonly string[];
16
+ readonly prompt: string;
17
+ readonly rules: readonly string[];
18
+ }
19
+
20
+ /** Minimal registry interface — compatible with @core/skill SkillRegistry */
21
+ export interface SkillProvider {
22
+ get(name: string): SkillEntry | undefined;
23
+ getAll(): readonly SkillEntry[];
24
+ }
25
+
26
+ /**
27
+ * An ITool that exposes the skill registry to the agent.
28
+ *
29
+ * Actions:
30
+ * - "list" → returns all available skill names and descriptions
31
+ * - "invoke" → returns the skill's prompt, tools, and rules so the
32
+ * agent can adopt that behavior for the current task
33
+ *
34
+ * This enables CLI-style `/skill` invocation: the agent discovers
35
+ * available skills and activates one by reading its prompt guidance.
36
+ */
37
+ export class SkillTool implements ITool {
38
+ readonly name = 'skill';
39
+ readonly requiresPermission = false;
40
+
41
+ private readonly registry: SkillProvider;
42
+ private readonly logger: AgentLogger;
43
+
44
+ constructor(registry: SkillProvider) {
45
+ this.registry = registry;
46
+ this.logger = createChildLogger('skill-tool');
47
+ }
48
+
49
+ describe(): ToolDescription {
50
+ return {
51
+ name: this.name,
52
+ description:
53
+ 'List or invoke predefined skills. ' +
54
+ 'Use action="list" to see available skills, ' +
55
+ 'or action="invoke" with name="<skill>" to activate a skill.',
56
+ parameters: [
57
+ {
58
+ name: 'action',
59
+ type: 'string',
60
+ description: 'Action to perform: "list" or "invoke"',
61
+ required: true,
62
+ },
63
+ {
64
+ name: 'name',
65
+ type: 'string',
66
+ description: 'Skill name (required for "invoke")',
67
+ required: false,
68
+ },
69
+ {
70
+ name: 'input',
71
+ type: 'string',
72
+ description: 'Optional context/input to pass to the skill',
73
+ required: false,
74
+ },
75
+ ],
76
+ };
77
+ }
78
+
79
+ async execute(params: JsonObject, _context: RunContext): Promise<ToolResult> {
80
+ const action = params['action'];
81
+
82
+ if (action === 'list') {
83
+ return this.listSkills();
84
+ }
85
+
86
+ if (action === 'invoke') {
87
+ const name = params['name'];
88
+ if (typeof name !== 'string' || name.trim().length === 0) {
89
+ return { success: false, output: '', error: 'Missing "name" parameter for invoke action' };
90
+ }
91
+ const input = typeof params['input'] === 'string' ? params['input'] : undefined;
92
+ return this.invokeSkill(name, input);
93
+ }
94
+
95
+ return {
96
+ success: false,
97
+ output: '',
98
+ error: `Unknown action: "${String(action)}". Use "list" or "invoke".`,
99
+ };
100
+ }
101
+
102
+ private listSkills(): ToolResult {
103
+ const skills = this.registry.getAll();
104
+ if (skills.length === 0) {
105
+ return { success: true, output: 'No skills available.' };
106
+ }
107
+
108
+ const lines = skills.map(
109
+ (s) => `- ${s.name}: ${s.description} [tools: ${s.tools.join(', ')}]`
110
+ );
111
+
112
+ this.logger.debug({ count: skills.length }, 'Listed skills');
113
+ return { success: true, output: `Available skills:\n${lines.join('\n')}` };
114
+ }
115
+
116
+ private invokeSkill(name: string, input?: string): ToolResult {
117
+ const skill = this.registry.get(name);
118
+ if (!skill) {
119
+ return { success: false, output: '', error: `Skill "${name}" not found` };
120
+ }
121
+
122
+ this.logger.info({ skill: name }, 'Skill invoked');
123
+
124
+ const sections: string[] = [
125
+ `## Skill: ${skill.name}`,
126
+ '',
127
+ skill.description,
128
+ '',
129
+ '### Prompt',
130
+ skill.prompt,
131
+ ];
132
+
133
+ if (skill.tools.length > 0) {
134
+ sections.push('', `### Available tools: ${skill.tools.join(', ')}`);
135
+ }
136
+
137
+ if (skill.rules.length > 0) {
138
+ sections.push('', `### Rules: ${skill.rules.join(', ')}`);
139
+ }
140
+
141
+ if (input) {
142
+ sections.push('', `### User input`, input);
143
+ }
144
+
145
+ return {
146
+ success: true,
147
+ output: sections.join('\n'),
148
+ metadata: {
149
+ skillName: skill.name,
150
+ tools: [...skill.tools],
151
+ rules: [...skill.rules],
152
+ },
153
+ };
154
+ }
155
+ }
@@ -0,0 +1,122 @@
1
+ import type {
2
+ ITool,
3
+ ILlmProvider,
4
+ ToolDescription,
5
+ ToolResult,
6
+ JsonObject,
7
+ AgentLogger,
8
+ } from '@charming_groot/core';
9
+ import { Registry, RunContext, createChildLogger } from '@charming_groot/core';
10
+ import { AgentLoop } from './agent-loop.js';
11
+ import type { SystemPromptBuilder } from './agent-loop.js';
12
+ import type { PermissionHandler } from './permission.js';
13
+
14
+ /** Configuration for creating a sub-agent tool. */
15
+ export interface SubAgentToolConfig {
16
+ /** Display name for this sub-agent tool */
17
+ readonly name: string;
18
+ /** Description shown to the parent agent */
19
+ readonly description: string;
20
+ /** LLM provider the sub-agent will use */
21
+ readonly provider: ILlmProvider;
22
+ /** Tools available to the sub-agent */
23
+ readonly toolRegistry: Registry<ITool>;
24
+ /** System prompt or dynamic builder for the sub-agent */
25
+ readonly systemPrompt?: string;
26
+ readonly systemPromptBuilder?: SystemPromptBuilder;
27
+ /** Max iterations for the sub-agent (default: 25) */
28
+ readonly maxIterations?: number;
29
+ /** Permission handler for the sub-agent's tools */
30
+ readonly permissionHandler?: PermissionHandler;
31
+ }
32
+
33
+ const DEFAULT_MAX_ITERATIONS = 25;
34
+
35
+ /**
36
+ * Wraps an AgentLoop as an ITool so a parent agent can delegate
37
+ * sub-tasks to a child agent via tool calls.
38
+ *
39
+ * The parent sends a "task" parameter; the sub-agent runs autonomously
40
+ * and returns the final result as tool output.
41
+ */
42
+ export class SubAgentTool implements ITool {
43
+ readonly name: string;
44
+ readonly requiresPermission = false;
45
+
46
+ private readonly config: SubAgentToolConfig;
47
+ private readonly logger: AgentLogger;
48
+
49
+ constructor(config: SubAgentToolConfig) {
50
+ this.name = config.name;
51
+ this.config = config;
52
+ this.logger = createChildLogger(`sub-agent:${config.name}`);
53
+ }
54
+
55
+ describe(): ToolDescription {
56
+ return {
57
+ name: this.name,
58
+ description: this.config.description,
59
+ parameters: [
60
+ {
61
+ name: 'task',
62
+ type: 'string',
63
+ description: 'The task to delegate to the sub-agent',
64
+ required: true,
65
+ },
66
+ ],
67
+ };
68
+ }
69
+
70
+ async execute(params: JsonObject, context: RunContext): Promise<ToolResult> {
71
+ const task = params['task'];
72
+ if (typeof task !== 'string' || task.trim().length === 0) {
73
+ return { success: false, output: '', error: 'Missing or empty "task" parameter' };
74
+ }
75
+
76
+ this.logger.info({ task: task.slice(0, 200) }, 'Sub-agent starting');
77
+
78
+ const childLoop = new AgentLoop({
79
+ provider: this.config.provider,
80
+ toolRegistry: this.config.toolRegistry,
81
+ config: {
82
+ provider: {
83
+ providerId: this.config.provider.providerId,
84
+ model: 'sub-agent',
85
+ auth: { type: 'api-key' as const, apiKey: '' },
86
+ maxTokens: 4096,
87
+ temperature: 0.7,
88
+ },
89
+ maxIterations: this.config.maxIterations ?? DEFAULT_MAX_ITERATIONS,
90
+ workingDirectory: context.workingDirectory,
91
+ systemPrompt: this.config.systemPrompt,
92
+ },
93
+ permissionHandler: this.config.permissionHandler,
94
+ systemPromptBuilder: this.config.systemPromptBuilder,
95
+ });
96
+
97
+ // Propagate abort from parent context to child via AbortSignal
98
+ const onAbort = () => childLoop.abort('Parent aborted');
99
+ context.signal.addEventListener('abort', onAbort, { once: true });
100
+
101
+ try {
102
+ const result = await childLoop.run(task);
103
+
104
+ this.logger.info(
105
+ { iterations: result.iterations, aborted: result.aborted },
106
+ 'Sub-agent completed',
107
+ );
108
+
109
+ if (result.aborted) {
110
+ return { success: false, output: result.content, error: 'Sub-agent was aborted' };
111
+ }
112
+
113
+ return { success: true, output: result.content };
114
+ } catch (error) {
115
+ const message = error instanceof Error ? error.message : String(error);
116
+ this.logger.warn({ error: message }, 'Sub-agent failed');
117
+ return { success: false, output: '', error: `Sub-agent error: ${message}` };
118
+ } finally {
119
+ context.signal.removeEventListener('abort', onAbort);
120
+ }
121
+ }
122
+ }
@@ -0,0 +1,79 @@
1
+ import { getEncoding, type Tiktoken } from 'js-tiktoken';
2
+ import type { Message } from '@charming_groot/core';
3
+
4
+ // cl100k_base: Claude, GPT-4, GPT-3.5-turbo
5
+ // o200k_base: GPT-4o, o1, o3
6
+ const MODEL_ENCODING: Record<string, string> = {
7
+ 'claude': 'cl100k_base',
8
+ 'gpt-4': 'cl100k_base',
9
+ 'gpt-3.5': 'cl100k_base',
10
+ 'gpt-4o': 'o200k_base',
11
+ 'o1': 'o200k_base',
12
+ 'o3': 'o200k_base',
13
+ };
14
+
15
+ const MESSAGE_OVERHEAD = 4; // role + formatting tokens per message
16
+ const REPLY_OVERHEAD = 3; // assistant reply priming tokens
17
+
18
+ let enc: Tiktoken | null = null;
19
+
20
+ function getEncoder(): Tiktoken {
21
+ if (!enc) {
22
+ // cl100k_base works well enough for Claude and most OpenAI models.
23
+ // For o200k_base models the count differs by ~3% — acceptable for budget tracking.
24
+ enc = getEncoding('cl100k_base');
25
+ }
26
+ return enc;
27
+ }
28
+
29
+ export function resolveEncoding(modelId: string): string {
30
+ const key = Object.keys(MODEL_ENCODING).find(k => modelId.toLowerCase().includes(k));
31
+ return key ? MODEL_ENCODING[key] : 'cl100k_base';
32
+ }
33
+
34
+ /**
35
+ * Count tokens in a plain text string.
36
+ */
37
+ export function countTextTokens(text: string): number {
38
+ if (!text) return 0;
39
+ try {
40
+ return getEncoder().encode(text).length;
41
+ } catch {
42
+ // Fallback: character estimate
43
+ return Math.ceil(text.length / 4);
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Count tokens for a single Message (content + tool calls + tool results).
49
+ * Mirrors OpenAI's message token counting spec closely enough for Claude too.
50
+ */
51
+ export function countMessageTokens(msg: Message): number {
52
+ let tokens = MESSAGE_OVERHEAD;
53
+
54
+ tokens += countTextTokens(msg.content);
55
+
56
+ if (msg.toolCalls) {
57
+ for (const tc of msg.toolCalls) {
58
+ tokens += countTextTokens(tc.name);
59
+ tokens += countTextTokens(tc.arguments);
60
+ tokens += 3; // function_call framing
61
+ }
62
+ }
63
+
64
+ if (msg.toolResults) {
65
+ for (const tr of msg.toolResults) {
66
+ tokens += countTextTokens(tr.content);
67
+ tokens += 3; // tool result framing
68
+ }
69
+ }
70
+
71
+ return tokens;
72
+ }
73
+
74
+ /**
75
+ * Count total tokens across a message history.
76
+ */
77
+ export function countHistoryTokens(messages: readonly Message[]): number {
78
+ return messages.reduce((sum, m) => sum + countMessageTokens(m), REPLY_OVERHEAD);
79
+ }
@@ -0,0 +1,124 @@
1
+ import type { ITool, ToolCall, ToolResult, ToolDescription, JsonObject } from '@charming_groot/core';
2
+ import {
3
+ Registry,
4
+ PermissionDeniedError,
5
+ createChildLogger,
6
+ } from '@charming_groot/core';
7
+ import type { RunContext, AgentLogger } from '@charming_groot/core';
8
+ import { PermissionManager } from './permission.js';
9
+
10
+ /** Maximum characters in a single tool result output */
11
+ const MAX_OUTPUT_CHARS = 80_000;
12
+ const TRUNCATION_NOTICE = '\n\n... [output truncated — exceeded 80,000 characters]';
13
+
14
+ export class ToolDispatcher {
15
+ private readonly toolRegistry: Registry<ITool>;
16
+ private readonly permissionManager: PermissionManager;
17
+ private readonly logger: AgentLogger;
18
+
19
+ constructor(toolRegistry: Registry<ITool>, permissionManager: PermissionManager) {
20
+ this.toolRegistry = toolRegistry;
21
+ this.permissionManager = permissionManager;
22
+ this.logger = createChildLogger('tool-dispatcher');
23
+ }
24
+
25
+ async dispatch(
26
+ toolCall: ToolCall,
27
+ context: RunContext
28
+ ): Promise<ToolResult> {
29
+ const tool = this.toolRegistry.tryGet(toolCall.name);
30
+ if (!tool) {
31
+ this.logger.warn({ toolName: toolCall.name }, 'Unknown tool');
32
+ return {
33
+ success: false,
34
+ output: '',
35
+ error: `Unknown tool: ${toolCall.name}`,
36
+ };
37
+ }
38
+
39
+ let params: JsonObject;
40
+ try {
41
+ params = JSON.parse(toolCall.arguments) as JsonObject;
42
+ } catch {
43
+ this.logger.warn(
44
+ { toolName: toolCall.name, arguments: toolCall.arguments.slice(0, 200) },
45
+ 'Invalid JSON in tool arguments',
46
+ );
47
+ return {
48
+ success: false,
49
+ output: '',
50
+ error: `Invalid tool arguments for "${toolCall.name}": ${toolCall.arguments.slice(0, 100)}`,
51
+ };
52
+ }
53
+
54
+ const permitted = await this.permissionManager.checkPermission(tool, params);
55
+ if (!permitted) {
56
+ this.logger.info({ toolName: toolCall.name }, 'Permission denied');
57
+ throw new PermissionDeniedError(toolCall.name);
58
+ }
59
+
60
+ const toolStartedAt = Date.now();
61
+ context.eventBus.emit('tool:start', { runId: context.runId, toolCall, startedAt: toolStartedAt });
62
+
63
+ let result: ToolResult;
64
+ try {
65
+ result = await tool.execute(params, context);
66
+ } catch (error) {
67
+ if (error instanceof PermissionDeniedError) throw error;
68
+ const message = error instanceof Error ? error.message : String(error);
69
+ result = { success: false, output: '', error: message };
70
+ }
71
+
72
+ const truncated = this.truncateResult(result);
73
+ context.eventBus.emit('tool:end', {
74
+ runId: context.runId,
75
+ toolCall,
76
+ result: truncated,
77
+ durationMs: Date.now() - toolStartedAt,
78
+ });
79
+ return truncated;
80
+ }
81
+
82
+ async dispatchAll(
83
+ toolCalls: readonly ToolCall[],
84
+ context: RunContext
85
+ ): Promise<ReadonlyMap<string, ToolResult>> {
86
+ const abortedResult: ToolResult = {
87
+ success: false,
88
+ output: '',
89
+ error: 'Operation aborted',
90
+ };
91
+
92
+ const entries = await Promise.all(
93
+ toolCalls.map(async (toolCall): Promise<[string, ToolResult]> => {
94
+ if (context.isAborted) {
95
+ return [toolCall.id, abortedResult];
96
+ }
97
+ const result = await this.dispatch(toolCall, context);
98
+ return [toolCall.id, result];
99
+ })
100
+ );
101
+
102
+ return new Map(entries);
103
+ }
104
+
105
+ private truncateResult(result: ToolResult): ToolResult {
106
+ if (result.output.length <= MAX_OUTPUT_CHARS) return result;
107
+ this.logger.info(
108
+ { originalLength: result.output.length, limit: MAX_OUTPUT_CHARS },
109
+ 'Tool output truncated',
110
+ );
111
+ return {
112
+ ...result,
113
+ output: result.output.slice(0, MAX_OUTPUT_CHARS) + TRUNCATION_NOTICE,
114
+ };
115
+ }
116
+
117
+ getToolDescriptions(): ToolDescription[] {
118
+ const descriptions: ToolDescription[] = [];
119
+ for (const [, tool] of this.toolRegistry.getAll()) {
120
+ descriptions.push(tool.describe());
121
+ }
122
+ return descriptions;
123
+ }
124
+ }