@agentforge-io/core 2.0.14 → 2.0.16
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/dist/domain/conversation.d.ts +22 -0
- package/dist/repositories/in-memory.d.ts +8 -1
- package/dist/repositories/in-memory.js +26 -0
- package/dist/repositories/index.d.ts +21 -1
- package/dist/services/oauth2.service.d.ts +18 -0
- package/dist/services/oauth2.service.js +33 -15
- package/package.json +1 -1
|
@@ -24,3 +24,25 @@ export interface Message {
|
|
|
24
24
|
}
|
|
25
25
|
export type NewConversation = Pick<Conversation, 'userId' | 'agentId'> & Partial<Omit<Conversation, 'id' | 'createdAt' | 'updatedAt'>>;
|
|
26
26
|
export type NewMessage = Omit<Message, 'id' | 'createdAt'>;
|
|
27
|
+
/**
|
|
28
|
+
* Minimal payload for the agent-editor's conversation sidebar. The full
|
|
29
|
+
* `Conversation + messages[]` is too heavy to ship for 50 rows; this
|
|
30
|
+
* shape carries the bare minimum the UI needs to render a row and
|
|
31
|
+
* decide which one to load in full.
|
|
32
|
+
*
|
|
33
|
+
* `intent` is the first user message of the conversation, truncated to
|
|
34
|
+
* 120 chars server-side. `null` when the conversation has no user
|
|
35
|
+
* message yet (rare but possible mid-create).
|
|
36
|
+
*/
|
|
37
|
+
export interface ConversationListItem {
|
|
38
|
+
id: string;
|
|
39
|
+
userId: string;
|
|
40
|
+
agentId: string;
|
|
41
|
+
status: ConversationStatus;
|
|
42
|
+
intent: string | null;
|
|
43
|
+
messageCount: number;
|
|
44
|
+
totalInputTokens: number;
|
|
45
|
+
totalOutputTokens: number;
|
|
46
|
+
createdAt: Date;
|
|
47
|
+
updatedAt: Date;
|
|
48
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ConversationRepository, MessageRepository } from './index';
|
|
2
|
-
import type { Conversation, NewConversation, Message, NewMessage } from '../domain/conversation';
|
|
2
|
+
import type { Conversation, ConversationListItem, NewConversation, Message, NewMessage } from '../domain/conversation';
|
|
3
3
|
import type { ConversationStatus } from '../types/agent.types';
|
|
4
4
|
export declare class InMemoryConversationRepository implements ConversationRepository {
|
|
5
5
|
private byId;
|
|
@@ -11,6 +11,13 @@ export declare class InMemoryConversationRepository implements ConversationRepos
|
|
|
11
11
|
limit?: number;
|
|
12
12
|
offset?: number;
|
|
13
13
|
}): Promise<Conversation[]>;
|
|
14
|
+
listForAgent(agentId: string, opts?: {
|
|
15
|
+
limit?: number;
|
|
16
|
+
offset?: number;
|
|
17
|
+
}): Promise<{
|
|
18
|
+
items: ConversationListItem[];
|
|
19
|
+
totalCount: number;
|
|
20
|
+
}>;
|
|
14
21
|
updateStats(id: string, patch: {
|
|
15
22
|
status?: ConversationStatus;
|
|
16
23
|
addInputTokens?: number;
|
|
@@ -41,6 +41,32 @@ class InMemoryConversationRepository {
|
|
|
41
41
|
const end = opts.limit ? start + opts.limit : undefined;
|
|
42
42
|
return items.slice(start, end);
|
|
43
43
|
}
|
|
44
|
+
async listForAgent(agentId, opts = {}) {
|
|
45
|
+
// In-memory variant doesn't have access to the message store —
|
|
46
|
+
// those live in a sibling repo. We return `intent: null` here; the
|
|
47
|
+
// TypeORM impl is the one that materializes intent from messages.
|
|
48
|
+
// Good enough for tests + demos that just want to exercise the
|
|
49
|
+
// listing contract.
|
|
50
|
+
const all = Array.from(this.byId.values()).filter((c) => c.agentId === agentId);
|
|
51
|
+
all.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime());
|
|
52
|
+
const start = opts.offset ?? 0;
|
|
53
|
+
const end = opts.limit ? start + opts.limit : undefined;
|
|
54
|
+
return {
|
|
55
|
+
items: all.slice(start, end).map((c) => ({
|
|
56
|
+
id: c.id,
|
|
57
|
+
userId: c.userId,
|
|
58
|
+
agentId: c.agentId,
|
|
59
|
+
status: c.status,
|
|
60
|
+
intent: null,
|
|
61
|
+
messageCount: c.messageCount,
|
|
62
|
+
totalInputTokens: c.totalInputTokens,
|
|
63
|
+
totalOutputTokens: c.totalOutputTokens,
|
|
64
|
+
createdAt: c.createdAt,
|
|
65
|
+
updatedAt: c.updatedAt,
|
|
66
|
+
})),
|
|
67
|
+
totalCount: all.length,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
44
70
|
async updateStats(id, patch) {
|
|
45
71
|
const c = this.byId.get(id);
|
|
46
72
|
if (!c)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Conversation, NewConversation, Message, NewMessage } from '../domain/conversation';
|
|
1
|
+
import type { Conversation, ConversationListItem, NewConversation, Message, NewMessage } from '../domain/conversation';
|
|
2
2
|
import type { ChatToken, NewChatToken, ChatTokenPatch } from '../domain/chat-token';
|
|
3
3
|
import type { McpServerRecord, NewMcpServerRecord, McpServerRecordPatch } from '../domain/mcp-server';
|
|
4
4
|
import type { ConnectorAuth, NewConnectorAuth, ConnectorAuthPatch } from '../domain/connector-auth';
|
|
@@ -22,6 +22,26 @@ export interface ConversationRepository {
|
|
|
22
22
|
limit?: number;
|
|
23
23
|
offset?: number;
|
|
24
24
|
}): Promise<Conversation[]>;
|
|
25
|
+
/**
|
|
26
|
+
* List every conversation an agent has had, regardless of user. Used by
|
|
27
|
+
* the agent-editor's "Conversations" panel — operators need visibility
|
|
28
|
+
* into all traffic the agent saw, not just their own.
|
|
29
|
+
*
|
|
30
|
+
* Tenant scoping is enforced one layer up (host service), since the SDK
|
|
31
|
+
* doesn't carry tenant context. Pass `agentId` only after verifying it
|
|
32
|
+
* belongs to the caller's tenant.
|
|
33
|
+
*
|
|
34
|
+
* The optional `intent` field on each item is the first user message's
|
|
35
|
+
* content, truncated to 120 chars — server-side so the wire payload
|
|
36
|
+
* stays small (the list is for a sidebar, not for replaying messages).
|
|
37
|
+
*/
|
|
38
|
+
listForAgent(agentId: string, opts?: {
|
|
39
|
+
limit?: number;
|
|
40
|
+
offset?: number;
|
|
41
|
+
}): Promise<{
|
|
42
|
+
items: ConversationListItem[];
|
|
43
|
+
totalCount: number;
|
|
44
|
+
}>;
|
|
25
45
|
updateStats(id: string, patch: {
|
|
26
46
|
status?: ConversationStatus;
|
|
27
47
|
addInputTokens?: number;
|
|
@@ -36,6 +36,24 @@ export interface OAuth2ProviderConfig {
|
|
|
36
36
|
* instead of letting an empty string poison the encrypted-token row.
|
|
37
37
|
*/
|
|
38
38
|
tokenExtractor?: (json: unknown) => TokenSet;
|
|
39
|
+
/**
|
|
40
|
+
* Where to put the client credentials on the token exchange request.
|
|
41
|
+
*
|
|
42
|
+
* - `'body'` (default) — appends `client_id` + `client_secret` to the
|
|
43
|
+
* form-encoded body. Works for the majority of providers (Google,
|
|
44
|
+
* HubSpot, ClickUp, Slack, GitHub).
|
|
45
|
+
*
|
|
46
|
+
* - `'basic'` — sends them as `Authorization: Basic
|
|
47
|
+
* base64(client_id:client_secret)` instead. Required by Notion
|
|
48
|
+
* (their `/v1/oauth/token` rejects body credentials with
|
|
49
|
+
* `invalid_client`), Spotify, and a handful of other providers
|
|
50
|
+
* whose docs say "Basic Auth" explicitly.
|
|
51
|
+
*
|
|
52
|
+
* RFC 6749 §2.3.1 allows both forms but recommends Basic when both are
|
|
53
|
+
* supported. We default to body because it was the legacy behavior and
|
|
54
|
+
* every existing connector relies on it.
|
|
55
|
+
*/
|
|
56
|
+
tokenAuth?: 'body' | 'basic';
|
|
39
57
|
}
|
|
40
58
|
export interface AuthorizeUrlResult {
|
|
41
59
|
url: string;
|
|
@@ -40,44 +40,62 @@ class OAuth2Service {
|
|
|
40
40
|
grant_type: 'authorization_code',
|
|
41
41
|
code,
|
|
42
42
|
redirect_uri: redirectUri,
|
|
43
|
-
client_id: cfg.clientId,
|
|
44
|
-
client_secret: cfg.clientSecret,
|
|
45
43
|
});
|
|
44
|
+
// Body-auth providers carry credentials in the form payload; basic-auth
|
|
45
|
+
// providers (Notion, Spotify) carry them in the Authorization header
|
|
46
|
+
// and reject body credentials with `invalid_client`. See
|
|
47
|
+
// OAuth2ProviderConfig.tokenAuth for the per-provider toggle.
|
|
48
|
+
if ((cfg.tokenAuth ?? 'body') === 'body') {
|
|
49
|
+
body.set('client_id', cfg.clientId);
|
|
50
|
+
body.set('client_secret', cfg.clientSecret);
|
|
51
|
+
}
|
|
46
52
|
if (pkceVerifier)
|
|
47
53
|
body.set('code_verifier', pkceVerifier);
|
|
48
|
-
return this.postToken(cfg
|
|
54
|
+
return this.postToken(cfg, body);
|
|
49
55
|
}
|
|
50
56
|
async refresh(cfg, refreshToken) {
|
|
51
57
|
const body = new URLSearchParams({
|
|
52
58
|
grant_type: 'refresh_token',
|
|
53
59
|
refresh_token: refreshToken,
|
|
54
|
-
client_id: cfg.clientId,
|
|
55
|
-
client_secret: cfg.clientSecret,
|
|
56
60
|
});
|
|
57
|
-
|
|
61
|
+
if ((cfg.tokenAuth ?? 'body') === 'body') {
|
|
62
|
+
body.set('client_id', cfg.clientId);
|
|
63
|
+
body.set('client_secret', cfg.clientSecret);
|
|
64
|
+
}
|
|
65
|
+
return this.postToken(cfg, body);
|
|
58
66
|
}
|
|
59
|
-
async postToken(
|
|
60
|
-
const
|
|
67
|
+
async postToken(cfg, body) {
|
|
68
|
+
const headers = {
|
|
69
|
+
'content-type': 'application/x-www-form-urlencoded',
|
|
70
|
+
accept: 'application/json',
|
|
71
|
+
};
|
|
72
|
+
// Basic-auth providers need credentials in the Authorization header
|
|
73
|
+
// instead of the body. RFC 6749 §2.3.1 — both forms are spec-legal
|
|
74
|
+
// but providers vary on which they accept.
|
|
75
|
+
if (cfg.tokenAuth === 'basic') {
|
|
76
|
+
const credentials = Buffer.from(`${cfg.clientId}:${cfg.clientSecret}`).toString('base64');
|
|
77
|
+
headers.authorization = `Basic ${credentials}`;
|
|
78
|
+
}
|
|
79
|
+
const res = await this.fetchImpl(cfg.tokenUrl, {
|
|
61
80
|
method: 'POST',
|
|
62
|
-
headers
|
|
63
|
-
'content-type': 'application/x-www-form-urlencoded',
|
|
64
|
-
accept: 'application/json',
|
|
65
|
-
},
|
|
81
|
+
headers,
|
|
66
82
|
body: body.toString(),
|
|
67
83
|
});
|
|
68
84
|
if (!res.ok) {
|
|
69
85
|
const text = await res.text().catch(() => '');
|
|
70
|
-
throw new Error(`OAuth2 token endpoint ${tokenUrl} returned ${res.status}: ${text}`);
|
|
86
|
+
throw new Error(`OAuth2 token endpoint ${cfg.tokenUrl} returned ${res.status}: ${text}`);
|
|
71
87
|
}
|
|
72
88
|
const json = (await res.json());
|
|
73
|
-
const tokens =
|
|
89
|
+
const tokens = cfg.tokenExtractor
|
|
90
|
+
? cfg.tokenExtractor(json)
|
|
91
|
+
: defaultExtractor(json);
|
|
74
92
|
// Guard against accidentally persisting an empty-string token — that
|
|
75
93
|
// would crash the cipher with "Received undefined" downstream and the
|
|
76
94
|
// operator would chase a confusing stack trace. Better to fail fast
|
|
77
95
|
// here with a descriptive error so the connector author knows their
|
|
78
96
|
// extractor (or the default) missed the token.
|
|
79
97
|
if (!tokens.accessToken) {
|
|
80
|
-
throw new Error(`OAuth2 token endpoint ${tokenUrl} returned a response without an access token. ` +
|
|
98
|
+
throw new Error(`OAuth2 token endpoint ${cfg.tokenUrl} returned a response without an access token. ` +
|
|
81
99
|
'If the provider uses a non-standard envelope, supply `tokenExtractor` on the ConnectorDefinition.');
|
|
82
100
|
}
|
|
83
101
|
return tokens;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agentforge-io/core",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.16",
|
|
4
4
|
"description": "Framework-free AI runtime SDK. Owns: agent loop (Anthropic), conversations, tools, streaming, agent-job queue, SdkHooks. Identity, billing, infra (email/uploads/secrets) live in the host's modules — not here.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "dist/index.js",
|