agentlas 1.0.18 → 1.0.20
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/CHANGELOG.md +25 -0
- package/README.md +190 -279
- package/engine/agents/router.cjs +111 -490
- package/engine/bootstrap-schema.sql +23 -21
- package/engine/commands/run.cjs +23 -34
- package/engine/project/controller.cjs +113 -0
- package/package.json +1 -1
package/engine/agents/router.cjs
CHANGED
|
@@ -1,345 +1,19 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
|
|
2
3
|
/*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* v1 모놀리스(legacy-v1-engine-snapshot engine/agentlas.cjs ~209-890)의 라우팅
|
|
6
|
-
* 로직을 v2 모듈 경계로 포팅했다. 하우스 룰(오너 결정, 위반 = 하드 실패):
|
|
4
|
+
* Model-judged routing for the explicit `route` capability.
|
|
7
5
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* 3. 웹 전용(private) 에이전트는 후보에 절대 들어가지 않는다 —
|
|
14
|
-
* registry.listRoutableAgents 가 유일한 후보 소스다.
|
|
15
|
-
*
|
|
16
|
-
* 닫힌형 결정적 가드(모델 호출 전, v1 동형):
|
|
17
|
-
* - 잡담 short-circuit (isTrivialRoutePrompt)
|
|
18
|
-
* - 경로 스트리핑 (ROUTE_PATH_RE — 경로 토큰이 strong 채널을 때리던 사고 수리)
|
|
19
|
-
* - 명시적 "에이전트/팀/회사 만들기" 의도 → 메타빌더 직행 (AGENT_BUILD_TERMS 게이트;
|
|
20
|
-
* 큐레이션된 닫힌형 의도 목록으로, 약한 키워드 점수 경쟁이 아니다)
|
|
6
|
+
* Ordinary `agentlas run` is project-first and does not call this module. This
|
|
7
|
+
* surface intentionally contains no regex intent gates, keyword dictionaries,
|
|
8
|
+
* phrase lists, lexical scores, deterministic specialist routes, or default
|
|
9
|
+
* agent substitution. The connected model may select an exact installed agent;
|
|
10
|
+
* otherwise the result remains unresolved.
|
|
21
11
|
*/
|
|
22
12
|
const crypto = require("node:crypto");
|
|
23
|
-
const { listRoutableAgents
|
|
24
|
-
|
|
25
|
-
const GLOBAL_ORCHESTRATOR_SLUG = "agentlas-orchestrator";
|
|
26
|
-
// 메타-빌더(에이전트/팀/회사 생성) — 약한 키워드 점수 경쟁에서 빼고, 명시적 build 의도일 때만 직행.
|
|
27
|
-
const META_BUILDER_SLUGS = ["agentlas-core-engine-meta-agent-builtin", "agentlas-meta-agent"];
|
|
28
|
-
const NON_GENERIC_ROUTE_SLUGS = new Set([GLOBAL_ORCHESTRATOR_SLUG, ...META_BUILDER_SLUGS]);
|
|
29
|
-
const AGENT_BUILD_TERMS = [
|
|
30
|
-
"build an agent", "build a team", "build me an agent", "build me a team", "make an agent",
|
|
31
|
-
"make a team", "create an agent", "create a team", "agent team", "agent for me",
|
|
32
|
-
"scaffold a team", "scaffold an agent", "build a company", "create a company",
|
|
33
|
-
"new agent team", "set up an agent", "set up a team", "spin up an agent",
|
|
34
|
-
"에이전트팀", "에이전트 팀", "에이전트 만들", "에이전트를 만들", "에이전트 좀 만들",
|
|
35
|
-
"에이전트 생성", "에이전트 구축", "팀 만들", "팀을 만들", "팀 생성", "회사 만들",
|
|
36
|
-
"회사를 만들", "에이전트 하나 만들",
|
|
37
|
-
];
|
|
38
|
-
// 한국어 조사("하나만","좀","를")가 끼면 고정 구문 매칭이 깨지므로, 엔티티+동사 근접 규칙을 보강한다.
|
|
39
|
-
const BUILD_ENTITY_RE = /(에이전트|agent|팀|team|회사|company)/i;
|
|
40
|
-
const BUILD_VERB_RE = /(만들|만든|생성|구축|구성해|꾸려|세팅|패키징|scaffold|build|create|\bmake\b|set\s?up|spin\s?up)/i;
|
|
41
|
-
|
|
42
|
-
// "ai"/"llm" 같은 초범용 토큰은 판별력이 0 — 이런 단어 하나로 전문 에이전트가 선택되던
|
|
43
|
-
// 오라우팅을 막는다. "local"/"imported"/"team"은 임포터 보일러플레이트/slug에 편재.
|
|
44
|
-
const ROUTE_STOP_WORDS = new Set(["the", "and", "for", "with", "this", "that", "from", "into", "make", "build", "create", "agent", "agents", "team", "please", "ai", "llm", "local", "imported", "인공지능", "에이아이", "좀", "해주세요", "해줘", "만들어", "붙여", "연결", "작업", "요청"]);
|
|
45
|
-
|
|
46
|
-
const ROUTE_HINTS = [
|
|
47
|
-
{
|
|
48
|
-
slug: "agentlas-app-builder",
|
|
49
|
-
terms: [
|
|
50
|
-
"apps generate", "app builder", "make an app", "build an app", "create an app",
|
|
51
|
-
"generated app", "generate app", "internal app", "dedicated app", "workflow app",
|
|
52
|
-
"dashboard app", "studio app", "service-app", "creative-studio", "scaffold-app",
|
|
53
|
-
"operate-app", "앱빌더", "앱 빌더", "앱 만들어", "앱 만들", "전용 앱", "내장 앱",
|
|
54
|
-
"내부 앱", "생성 앱", "워크플로우 앱", "대시보드 앱", "스튜디오 앱",
|
|
55
|
-
],
|
|
56
|
-
reasonKo: "Agentlas 안에서 열리는 내부 App 생성/설계 요청입니다",
|
|
57
|
-
reasonEn: "the request is to create or design an internal Agentlas App",
|
|
58
|
-
},
|
|
59
|
-
{
|
|
60
|
-
slug: "agentlas-memory-curator",
|
|
61
|
-
terms: ["memory", "remember", "recall", "request_context", "context_json", "메모리", "기억", "회상", "저장"],
|
|
62
|
-
reasonKo: "기억 저장/검색/스코프 품질을 다루는 요청입니다",
|
|
63
|
-
reasonEn: "the request concerns memory storage, recall, or scope quality",
|
|
64
|
-
},
|
|
65
|
-
{
|
|
66
|
-
slug: "agentlas-task-bias",
|
|
67
|
-
terms: ["bias", "sitemap", "evidence", "completion", "coverage", "편향", "사이트맵", "증거", "검증"],
|
|
68
|
-
reasonKo: "작업 편향, 사이트맵, 검증 증거를 다루는 요청입니다",
|
|
69
|
-
reasonEn: "the request concerns task bias, sitemap, or validation evidence",
|
|
70
|
-
},
|
|
71
|
-
{
|
|
72
|
-
slug: "agentlas-pm-soul",
|
|
73
|
-
terms: ["project", "plan", "decision", "handoff", "continuity", "프로젝트", "계획", "결정", "연속성", "핸드오프"],
|
|
74
|
-
reasonKo: "프로젝트 연속성/결정/조율이 중심인 요청입니다",
|
|
75
|
-
reasonEn: "the request is centered on project continuity, decisions, or coordination",
|
|
76
|
-
},
|
|
77
|
-
];
|
|
78
|
-
|
|
79
|
-
function routeNormalize(value) {
|
|
80
|
-
return String(value || "").toLowerCase().replace(/[_/]+/g, "-");
|
|
81
|
-
}
|
|
82
|
-
// 경로 디렉터리 성분은 라우팅 의도가 아니다 — 마지막 세그먼트(파일/폴더명)만 남긴다.
|
|
83
|
-
// (v1 사고 2026-07-12: 프롬프트 속 절대경로 토큰이 임포트 에이전트 system_prompt 속
|
|
84
|
-
// 절대경로와 우연 일치해 점수를 쌓고 strong 게이트까지 뚫었다. 대칭 적용 필수.)
|
|
85
|
-
const ROUTE_PATH_RE = /(^|[\s"'`(<\[{])((?:~|[A-Za-z]:)?[\\/]{1,2}(?:[^\s\\/]+(?: [A-Z][^\s\\/]*)?[\\/]+){2,}[^\s\\/]*|(?:[^\s\\/]+[\\/]+){2,}[^\s\\/]+\.[A-Za-z0-9]{1,6})/g;
|
|
86
|
-
function routeStripPaths(value) {
|
|
87
|
-
return String(value || "").replace(ROUTE_PATH_RE, (whole, pre, p) => {
|
|
88
|
-
const segs = p.split(/[\\/]+/).filter(Boolean);
|
|
89
|
-
return pre + (segs.length ? segs[segs.length - 1] : "");
|
|
90
|
-
});
|
|
91
|
-
}
|
|
92
|
-
function routeTokenize(value) {
|
|
93
|
-
// 매치가 영숫자로 끝나도록 강제해 "users-mason-documents-" 같은 후행 하이픈 토큰을 원천 차단.
|
|
94
|
-
const matches = routeNormalize(routeStripPaths(value)).match(/[a-z0-9][a-z0-9-]*[a-z0-9]|[가-힣]{2,}/g) || [];
|
|
95
|
-
const expanded = matches.flatMap((term) => term.split("-").filter(Boolean).concat(term));
|
|
96
|
-
return [...new Set(expanded.filter((term) => term.length >= 2 && !ROUTE_STOP_WORDS.has(term)))];
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
// 정체성 존(slug/이름/태그라인) 적중은 강한 신호. systemPrompt 본문 적중은 약한 신호.
|
|
100
|
-
function routeIdentityHaystack(agent) {
|
|
101
|
-
return routeNormalize(routeStripPaths([agent.slug, agent.name, agent.nameEn, agent.tagline, agent.taglineEn].join("\n")));
|
|
102
|
-
}
|
|
103
|
-
function routeHaystack(agent) {
|
|
104
|
-
return routeNormalize(routeStripPaths([
|
|
105
|
-
agent.slug, agent.name, agent.nameEn, agent.tagline, agent.taglineEn,
|
|
106
|
-
String(agent.systemPrompt || "").slice(0, 3500),
|
|
107
|
-
].join("\n")));
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
const APP_BUILDER_EXPLICIT_TERMS = ROUTE_HINTS[0].terms;
|
|
111
|
-
const APP_BUILDER_REPEAT_TERMS = [
|
|
112
|
-
"automation", "automate", "automatic", "recurring", "repeat", "scheduled",
|
|
113
|
-
"scheduler", "every day", "every week", "workflow", "pipeline", "cron",
|
|
114
|
-
"자동화", "자동", "반복", "정기", "매일", "매주", "스케줄", "예약",
|
|
115
|
-
"워크플로우", "파이프라인",
|
|
116
|
-
];
|
|
117
|
-
const APP_BUILDER_SURFACE_TERMS = [
|
|
118
|
-
"dashboard", "studio", "editor", "settings", "state", "save", "saved",
|
|
119
|
-
"export", "import", "approve", "approval", "review", "queue", "table",
|
|
120
|
-
"filter", "template", "memory", "profile", "대시보드", "스튜디오", "편집",
|
|
121
|
-
"수정", "설정", "상태", "저장", "내보내기", "불러오기", "승인", "검토",
|
|
122
|
-
"큐", "목록", "테이블", "필터", "템플릿", "학습", "메모리", "프로필",
|
|
123
|
-
];
|
|
124
|
-
const APP_BUILDER_ACTION_TERMS = [
|
|
125
|
-
"build", "create", "generate", "compose", "manage", "track", "research",
|
|
126
|
-
"analyze", "monitor", "render", "convert", "만들", "생성", "작성", "관리",
|
|
127
|
-
"추적", "리서치", "조사", "분석", "모니터", "렌더", "변환",
|
|
128
|
-
];
|
|
129
|
-
const TRIVIAL_ROUTE_PROMPTS = new Set(["hi", "hello", "hey", "thanks", "thankyou", "안녕", "안녕하세요", "고마워", "감사", "뭐해"]);
|
|
130
|
-
|
|
131
|
-
function routeIncludesTerm(haystack, term) {
|
|
132
|
-
return haystack.includes(routeNormalize(term));
|
|
133
|
-
}
|
|
134
|
-
function routeMatchedTerms(promptText, terms) {
|
|
135
|
-
return [...new Set(terms.filter((term) => routeIncludesTerm(promptText, term)))];
|
|
136
|
-
}
|
|
137
|
-
function isTrivialRoutePrompt(promptText) {
|
|
138
|
-
const compact = String(promptText || "").replace(/\s+/g, " ").trim();
|
|
139
|
-
const stripped = compact.replace(/[.!?~。!?,,ㅋㅎ\s]/g, "");
|
|
140
|
-
if (!stripped) return true;
|
|
141
|
-
if (stripped.length <= 18 && TRIVIAL_ROUTE_PROMPTS.has(stripped)) return true;
|
|
142
|
-
const words = compact.split(/\s+/).filter(Boolean);
|
|
143
|
-
return words.length <= 3 && TRIVIAL_ROUTE_PROMPTS.has(stripped);
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
function isAgentBuildIntent(prompt) {
|
|
147
|
-
// 경로/파일 참조는 빌드 의도의 증거가 아니다 — "agent-notes.md" 같은 파일명이
|
|
148
|
-
// BUILD_ENTITY_RE를 때려 메타빌더(score 1000)로 직행하던 우회로 차단(v1 수리 유지).
|
|
149
|
-
// ⚠️ 남은 슬래시는 통째로 지우지 않고 공백으로만 벌린다 — "에이전트/팀 만들어줘"의
|
|
150
|
-
// 슬래시-엔티티를 삭제하면 빌드 의도를 놓친다.
|
|
151
|
-
const p = routeNormalize(
|
|
152
|
-
routeStripPaths(prompt)
|
|
153
|
-
.replace(/\S+\.[A-Za-z0-9]{1,6}(?=\s|$)/g, " ")
|
|
154
|
-
.replace(/[\\/]+/g, " "),
|
|
155
|
-
);
|
|
156
|
-
if (!p.trim() || isTrivialRoutePrompt(p)) return false;
|
|
157
|
-
if (AGENT_BUILD_TERMS.some((term) => p.includes(routeNormalize(term)))) return true;
|
|
158
|
-
return BUILD_ENTITY_RE.test(p) && BUILD_VERB_RE.test(p);
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/** 메타빌더 해석 — private 필터를 우회한 직접 조회(v1 동형): 메타빌더는 목록/일반
|
|
162
|
-
* 스코어링에서는 숨겨지지만, 명시적 build 의도로는 직행 라우팅되어야 한다. */
|
|
163
|
-
function resolveMetaBuilder(db) {
|
|
164
|
-
try {
|
|
165
|
-
const rows = db
|
|
166
|
-
.prepare("SELECT * FROM installed_agents WHERE slug IN ('agentlas-core-engine-meta-agent-builtin','agentlas-meta-agent')")
|
|
167
|
-
.all();
|
|
168
|
-
for (const slug of META_BUILDER_SLUGS) {
|
|
169
|
-
const row = rows.find((r) => r.slug === slug);
|
|
170
|
-
if (row) return rowToAgent(row);
|
|
171
|
-
}
|
|
172
|
-
} catch { /* ignore */ }
|
|
173
|
-
return null;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
/** 판정 폴백용 기본 에이전트: 오케스트레이터 → 첫 visible 에이전트. */
|
|
177
|
-
function resolveDefaultRouteAgent(db) {
|
|
178
|
-
try {
|
|
179
|
-
const routable = listRoutableAgents(db);
|
|
180
|
-
const orch = routable.find((a) => a.slug === GLOBAL_ORCHESTRATOR_SLUG);
|
|
181
|
-
if (orch) return orch;
|
|
182
|
-
const visible = listAgents(db);
|
|
183
|
-
return visible[0] || routable[0] || null;
|
|
184
|
-
} catch {
|
|
185
|
-
return null;
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
function isAppBuilderWorthyRoutePrompt(prompt) {
|
|
190
|
-
const promptText = routeNormalize(routeStripPaths(prompt));
|
|
191
|
-
if (!promptText.trim() || isTrivialRoutePrompt(promptText)) return false;
|
|
192
|
-
const explicit = routeMatchedTerms(promptText, APP_BUILDER_EXPLICIT_TERMS);
|
|
193
|
-
if (explicit.length) return true;
|
|
194
|
-
const repeat = routeMatchedTerms(promptText, APP_BUILDER_REPEAT_TERMS);
|
|
195
|
-
const surface = routeMatchedTerms(promptText, APP_BUILDER_SURFACE_TERMS);
|
|
196
|
-
const action = routeMatchedTerms(promptText, APP_BUILDER_ACTION_TERMS);
|
|
197
|
-
const signalCount = new Set([...repeat, ...surface, ...action]).size;
|
|
198
|
-
if (repeat.length && (surface.length || action.length)) return true;
|
|
199
|
-
if (surface.length >= 2 && action.length) return true;
|
|
200
|
-
return signalCount >= 4;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
function routeHint(promptText, agent, lang) {
|
|
204
|
-
const hint = ROUTE_HINTS.find((item) => item.slug === agent.slug);
|
|
205
|
-
if (!hint) return { score: 0, terms: [], reason: "" };
|
|
206
|
-
if (hint.slug === "agentlas-app-builder" && !isAppBuilderWorthyRoutePrompt(promptText)) {
|
|
207
|
-
return { score: 0, terms: [], reason: "" };
|
|
208
|
-
}
|
|
209
|
-
const terms = hint.terms.filter((term) => promptText.includes(routeNormalize(term)));
|
|
210
|
-
if (!terms.length) return { score: 0, terms: [], reason: "" };
|
|
211
|
-
return { score: 12 + terms.length * 3, terms, reason: lang === "ko" ? hint.reasonKo : hint.reasonEn };
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
function scoreRouteAgent(prompt, promptTerms, agent, lang, pre) {
|
|
215
|
-
// 대칭 스트리핑 필수: promptText는 이름(+20)·힌트(+12↑) strong 채널의 입력이다.
|
|
216
|
-
const promptText = routeNormalize(routeStripPaths(prompt));
|
|
217
|
-
if (agent.slug === "agentlas-app-builder" && !isAppBuilderWorthyRoutePrompt(promptText)) {
|
|
218
|
-
return {
|
|
219
|
-
agent,
|
|
220
|
-
score: 0,
|
|
221
|
-
reason: lang === "ko"
|
|
222
|
-
? "전용 App을 만들 만큼 반복·상태·편집·자동화가 뚜렷하지 않아 App Builder 라우트를 보류했습니다"
|
|
223
|
-
: "the request does not clearly need a dedicated App with durable workflow, state, editing, or automation",
|
|
224
|
-
terms: [],
|
|
225
|
-
strong: false,
|
|
226
|
-
};
|
|
227
|
-
}
|
|
228
|
-
const identityHay = (pre && pre.identityHay) || routeIdentityHaystack(agent);
|
|
229
|
-
const haystack = (pre && pre.haystack) || routeHaystack(agent);
|
|
230
|
-
let score = 0;
|
|
231
|
-
let strong = false; // 이름 언급/정체성 적중/큐레이션 힌트 — strong 증거가 있어야 후보로서 의미가 있다
|
|
232
|
-
const terms = [];
|
|
233
|
-
const seenNames = new Set();
|
|
234
|
-
for (const name of [agent.slug, agent.name, agent.nameEn].filter(Boolean)) {
|
|
235
|
-
const n = routeNormalize(name);
|
|
236
|
-
// 4자 미만 일반 단어가 +20을 독식하지 않도록 가드; name===nameEn 중복 +20 방지.
|
|
237
|
-
if (!n || n.length < 4 || seenNames.has(n)) continue;
|
|
238
|
-
seenNames.add(n);
|
|
239
|
-
if (promptText.includes(n)) {
|
|
240
|
-
score += 20;
|
|
241
|
-
terms.push(name);
|
|
242
|
-
strong = true;
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
for (const term of promptTerms) {
|
|
246
|
-
if (identityHay.includes(term)) {
|
|
247
|
-
score += 6;
|
|
248
|
-
terms.push(term);
|
|
249
|
-
strong = true;
|
|
250
|
-
} else if (haystack.includes(term)) {
|
|
251
|
-
score += term.length >= 5 ? 3 : 2;
|
|
252
|
-
terms.push(term);
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
const hint = routeHint(promptText, agent, lang);
|
|
256
|
-
score += hint.score;
|
|
257
|
-
if (hint.score) strong = true;
|
|
258
|
-
terms.push(...hint.terms);
|
|
259
|
-
const unique = [...new Set(terms)].slice(0, 6);
|
|
260
|
-
const reason = hint.reason || (lang === "ko"
|
|
261
|
-
? unique.length
|
|
262
|
-
? `요청어 ${unique.map((term) => `"${term}"`).join(", ")}가 이 에이전트의 역할/트리거와 가장 가깝습니다`
|
|
263
|
-
: "명확한 전문 라우트가 없어 기본 프로젝트 조율 에이전트가 가장 안전합니다"
|
|
264
|
-
: unique.length
|
|
265
|
-
? `request terms ${unique.map((term) => `"${term}"`).join(", ")} best match this agent's role/triggers`
|
|
266
|
-
: "no specialist matched clearly, so the default project coordinator is safest");
|
|
267
|
-
return { agent, score, reason, terms: unique, strong };
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
/**
|
|
271
|
-
* 어휘 스코어링 — 후보 "모집" 전용(recall widening). 하우스 룰: 이 점수는 절대 최종
|
|
272
|
-
* 라우트를 결정하지 않는다. 후보는 listRoutableAgents(웹 전용 private 제외)만 쓴다.
|
|
273
|
-
*/
|
|
274
|
-
function rankRouteAgents(db, prompt, lang) {
|
|
275
|
-
const resolvedLang = lang || "en";
|
|
276
|
-
const agents = listRoutableAgents(db).filter((agent) => !NON_GENERIC_ROUTE_SLUGS.has(agent.slug));
|
|
277
|
-
if (!agents.length) return [];
|
|
278
|
-
let terms = routeTokenize(prompt);
|
|
279
|
-
const hays = agents.map((agent) => ({ identityHay: routeIdentityHaystack(agent), haystack: routeHaystack(agent) }));
|
|
280
|
-
// IDF 근사 — 설치 에이전트 절반 이상의 haystack에 나오는 단어는 판별력이 없어 제외.
|
|
281
|
-
if (agents.length >= 3) {
|
|
282
|
-
terms = terms.filter((term) => hays.filter((h) => h.haystack.includes(term)).length * 2 <= agents.length);
|
|
283
|
-
}
|
|
284
|
-
return agents
|
|
285
|
-
.map((agent, i) => scoreRouteAgent(prompt, terms, agent, resolvedLang, hays[i]))
|
|
286
|
-
.sort((a, b) => b.score - a.score);
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
function directRouteChoice(lang) {
|
|
290
|
-
return {
|
|
291
|
-
direct: true,
|
|
292
|
-
agent: null,
|
|
293
|
-
score: 0,
|
|
294
|
-
terms: [],
|
|
295
|
-
strong: false,
|
|
296
|
-
reason: lang === "ko"
|
|
297
|
-
? "특정 전문 에이전트가 필요 없는 일반 요청입니다"
|
|
298
|
-
: "this is a general request that needs no specialist agent",
|
|
299
|
-
};
|
|
300
|
-
}
|
|
13
|
+
const { listRoutableAgents } = require("./registry.cjs");
|
|
301
14
|
|
|
302
|
-
|
|
303
|
-
* 판정 불가 정직 폴백(하우스 룰): 어휘로 전문 에이전트를 고르지 않는다. 기본
|
|
304
|
-
* 에이전트(오케스트레이터)로 가되 사유를 note로 반드시 노출한다 — 조용한 폴백 금지.
|
|
305
|
-
* reason 구분: "no_runtime"=연결 모델 없음, "model_unavailable"=모델은 연결됐지만
|
|
306
|
-
* 이번 판정이 타임아웃/불량 응답으로 실패.
|
|
307
|
-
*/
|
|
308
|
-
function noModelRouteChoice(db, lang, reason = "no_runtime") {
|
|
309
|
-
const fallbackAgent = resolveDefaultRouteAgent(db);
|
|
310
|
-
const message = reason === "model_unavailable"
|
|
311
|
-
? (lang === "ko"
|
|
312
|
-
? "연결된 모델이 제때 응답하지 않아 어떤 전문 에이전트가 맞는지 판단하지 못했습니다. 잠시 후 다시 시도하거나 모델 상태를 확인해 주세요. 지금은 기본 에이전트로 실행합니다"
|
|
313
|
-
: "the connected model didn't answer in time, so I couldn't judge which specialist agent fits; retry in a moment or check the model — running with the default agent for now")
|
|
314
|
-
: (lang === "ko"
|
|
315
|
-
? "연결된 모델이 없어 어떤 전문 에이전트가 맞는지 판단하지 못했습니다. 모델을 연결하면 자동 라우팅이 됩니다. 지금은 기본 에이전트로 실행합니다"
|
|
316
|
-
: "no model is connected, so I couldn't judge which specialist agent fits; connect a model to enable auto-routing — running with the default agent for now");
|
|
317
|
-
return {
|
|
318
|
-
direct: !fallbackAgent,
|
|
319
|
-
agent: fallbackAgent,
|
|
320
|
-
score: 0,
|
|
321
|
-
terms: [],
|
|
322
|
-
strong: false,
|
|
323
|
-
noModel: true,
|
|
324
|
-
noModelReason: reason,
|
|
325
|
-
routeSource: "deterministic",
|
|
326
|
-
reason: message,
|
|
327
|
-
};
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
/** 직답 모드 시스템 프롬프트 — 페르소나·라우팅 오염 없이 현재 런타임 그대로 답한다. */
|
|
331
|
-
function directSystemPrompt(lang) {
|
|
332
|
-
return lang === "ko"
|
|
333
|
-
? "당신은 Agentlas 터미널의 기본 어시스턴트입니다. 특별한 페르소나 없이 사용자의 요청에 정확하고 간결하게 바로 답하세요. 에이전트 라우팅이나 이미지 생성 능력을 스스로 언급하지 마세요."
|
|
334
|
-
: "You are the Agentlas terminal's default assistant. Answer the user's request directly and concisely, with no special persona. Do not bring up agent routing or image-generation capabilities on your own.";
|
|
335
|
-
}
|
|
15
|
+
const UNRESOLVED_LABEL = "unresolved";
|
|
336
16
|
|
|
337
|
-
// ── 판정 러너 배선 ─────────────────────────────────────────
|
|
338
|
-
// 하우스 룰: 모델 접근은 주입식(setJudgmentRunner). 이미 러너가 설치돼 있으면 절대
|
|
339
|
-
// 덮어쓰지 않는다(테스트의 fake 러너 / 상위 호스트의 배선 존중). 없을 때만, 해석된
|
|
340
|
-
// 런타임으로 헤드리스 캡처(workforce/capture.cjs captureRuntime) 러너를 깐다.
|
|
341
|
-
// BYOK/Ollama는 같은 모듈의 runApi 원샷 경로 — "연결 모델이 판정한다"는 계약이
|
|
342
|
-
// CLI 서브프로세스 런타임에만 성립하는 반쪽이 되지 않게 한다(v1 수리 유지).
|
|
343
17
|
function ensureJudgeRunner(db, runtime) {
|
|
344
18
|
let judgment;
|
|
345
19
|
try {
|
|
@@ -348,34 +22,42 @@ function ensureJudgeRunner(db, runtime) {
|
|
|
348
22
|
return null;
|
|
349
23
|
}
|
|
350
24
|
if (judgment.hasJudgmentRunner()) return judgment;
|
|
25
|
+
|
|
351
26
|
const capture = require("../workforce/capture.cjs");
|
|
352
|
-
let
|
|
353
|
-
if (!
|
|
354
|
-
// 명시 런타임이 없으면 공유 DB의 active_runtime(데스크탑이 확정한 런타임)로 시도.
|
|
27
|
+
let resolved = runtime || null;
|
|
28
|
+
if (!resolved && db) {
|
|
355
29
|
try {
|
|
356
|
-
const
|
|
357
|
-
if (
|
|
358
|
-
|
|
359
|
-
else if (
|
|
360
|
-
|
|
30
|
+
const active = require("../runtimes/detect.cjs").activeRuntimeRow(db);
|
|
31
|
+
if (active && capture.RUNTIME_BIN[active.kind]) {
|
|
32
|
+
resolved = { kind: active.kind, model: active.model || null };
|
|
33
|
+
} else if (active && active.kind === "byok" && active.backend) {
|
|
34
|
+
resolved = { kind: "byok", backend: active.backend, model: active.model || null };
|
|
35
|
+
} else if (active && active.kind === "ollama") {
|
|
36
|
+
resolved = { kind: "ollama", model: active.model || null };
|
|
37
|
+
}
|
|
38
|
+
} catch {
|
|
39
|
+
resolved = null;
|
|
40
|
+
}
|
|
361
41
|
}
|
|
362
|
-
|
|
42
|
+
|
|
43
|
+
if (resolved && capture.RUNTIME_BIN[resolved.kind]) {
|
|
363
44
|
judgment.setJudgmentRunner(async ({ system, prompt, signal }) => {
|
|
364
45
|
try {
|
|
365
|
-
return await capture.captureRuntime(
|
|
46
|
+
return await capture.captureRuntime(resolved.kind, system, prompt, {
|
|
366
47
|
cwd: capture.projectCwd(),
|
|
367
48
|
permission: "read",
|
|
368
|
-
model:
|
|
49
|
+
model: resolved.model || undefined,
|
|
369
50
|
signal,
|
|
370
51
|
});
|
|
371
52
|
} catch {
|
|
372
|
-
return "";
|
|
53
|
+
return "";
|
|
373
54
|
}
|
|
374
55
|
});
|
|
375
56
|
return judgment;
|
|
376
57
|
}
|
|
377
|
-
|
|
378
|
-
|
|
58
|
+
|
|
59
|
+
if (resolved && (resolved.kind === "byok" || resolved.kind === "ollama")) {
|
|
60
|
+
const backend = resolved.kind === "ollama" ? "ollama" : resolved.backend;
|
|
379
61
|
if (backend) {
|
|
380
62
|
judgment.setJudgmentRunner(async ({ system, prompt, signal }) => {
|
|
381
63
|
const baseFetch = globalThis.fetch;
|
|
@@ -383,7 +65,7 @@ function ensureJudgeRunner(db, runtime) {
|
|
|
383
65
|
? (url, init) => baseFetch(url, { ...(init || {}), signal })
|
|
384
66
|
: baseFetch;
|
|
385
67
|
try {
|
|
386
|
-
return await capture.runApi(backend,
|
|
68
|
+
return await capture.runApi(backend, resolved.model || null, system, prompt, { fetch: fetchImpl });
|
|
387
69
|
} catch {
|
|
388
70
|
return "";
|
|
389
71
|
}
|
|
@@ -394,172 +76,111 @@ function ensureJudgeRunner(db, runtime) {
|
|
|
394
76
|
return judgment;
|
|
395
77
|
}
|
|
396
78
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
79
|
+
function unresolvedChoice(lang, reason, noModelReason = null) {
|
|
80
|
+
return {
|
|
81
|
+
agent: null,
|
|
82
|
+
unresolved: true,
|
|
83
|
+
routeSource: "unresolved",
|
|
84
|
+
noModel: Boolean(noModelReason),
|
|
85
|
+
noModelReason,
|
|
86
|
+
reason: reason || (lang === "ko"
|
|
87
|
+
? "요청을 맡을 에이전트를 확정하지 못했습니다"
|
|
88
|
+
: "No agent was confirmed for this request"),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function autoRouteNote(choice, lang) {
|
|
93
|
+
if (!choice || choice.unresolved || !choice.agent) {
|
|
94
|
+
return lang === "ko"
|
|
95
|
+
? `에이전트를 확정하지 않았습니다. ${choice?.reason || "현재 판단 근거가 충분하지 않습니다."}`
|
|
96
|
+
: `No agent was confirmed. ${choice?.reason || "There is not enough evidence to decide."}`;
|
|
97
|
+
}
|
|
98
|
+
const name = lang === "ko" ? choice.agent.name : choice.agent.nameEn || choice.agent.name;
|
|
99
|
+
return lang === "ko"
|
|
100
|
+
? `사용 에이전트: ${name}. 이유: ${choice.reason}.`
|
|
101
|
+
: `Selected agent: ${name}. Reason: ${choice.reason}.`;
|
|
102
|
+
}
|
|
403
103
|
|
|
404
|
-
/**
|
|
405
|
-
* 자동 라우팅 최종 판정.
|
|
406
|
-
* @param db 공유 SQLite
|
|
407
|
-
* @param task 사용자 요청 원문
|
|
408
|
-
* @param opts { lang?, signal?, runtime?, timeoutMs? }
|
|
409
|
-
* runtime: runtimes/resolve.cjs 가 확정한 런타임 — 판정 러너 배선에 쓴다.
|
|
410
|
-
* @returns choice = { agent|null, direct?, score, terms, strong, reason,
|
|
411
|
-
* routeSource: "llm"|"deterministic", noModel?, noModelReason?, note }
|
|
412
|
-
* note는 사용자에게 반드시 출력해야 하는 한 줄(조용한 라우팅 금지).
|
|
413
|
-
*/
|
|
414
104
|
async function resolveAutoRoute(db, task, opts = {}) {
|
|
415
105
|
const lang = opts.lang === "ko" ? "ko" : "en";
|
|
416
106
|
const finish = (choice) => ({ ...choice, note: autoRouteNote(choice, lang) });
|
|
417
|
-
const
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
if (!promptText.trim() || isTrivialRoutePrompt(promptText)) {
|
|
422
|
-
return finish({ ...directRouteChoice(lang), routeSource: "deterministic" });
|
|
423
|
-
}
|
|
424
|
-
// 명확한 "에이전트/팀/회사 만들기" 의도 → 메타빌더 직행. AGENT_BUILD_TERMS는
|
|
425
|
-
// 큐레이션된 닫힌형 의도 게이트(잡담 가드와 같은 계층)이지 점수 경쟁이 아니다 —
|
|
426
|
-
// 명시적 build 구문일 때만 발화하고, 파일명/경로로는 발화하지 않는다.
|
|
427
|
-
if (isAgentBuildIntent(task)) {
|
|
428
|
-
const meta = resolveMetaBuilder(db);
|
|
429
|
-
if (meta) {
|
|
430
|
-
return finish({
|
|
431
|
-
agent: meta,
|
|
432
|
-
score: 1000,
|
|
433
|
-
strong: true,
|
|
434
|
-
terms: [],
|
|
435
|
-
routeSource: "deterministic",
|
|
436
|
-
reason: lang === "ko"
|
|
437
|
-
? "새 에이전트/팀/회사를 만드는 요청이라 메타에이전트(빌더)로 라우팅했습니다"
|
|
438
|
-
: "the request is to build a new agent/team/company, so it routes to the meta-agent (builder)",
|
|
439
|
-
});
|
|
440
|
-
}
|
|
441
|
-
}
|
|
442
|
-
// 연결 모델 없음 → 어휘로 전문 에이전트를 고르지 않는다. 정직한 기본 에이전트 폴백 + note.
|
|
443
|
-
if (!judgment || !judgment.hasJudgmentRunner()) {
|
|
444
|
-
return finish(noModelRouteChoice(db, lang, "no_runtime"));
|
|
107
|
+
const input = String(task || "").trim();
|
|
108
|
+
if (!input) {
|
|
109
|
+
return finish(unresolvedChoice(lang,
|
|
110
|
+
lang === "ko" ? "판단할 요청이 없습니다" : "There is no request to judge"));
|
|
445
111
|
}
|
|
446
112
|
|
|
447
|
-
const
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
}
|
|
452
|
-
// App Builder는 합성 라벨로만 제시(동의 핸드셰이크가 걸린 특수 라우트임을 모델에 명시).
|
|
453
|
-
const appBuilder = ranked.find((r) => r.agent.slug === APP_BUILDER_ROUTE_SLUG) || null;
|
|
454
|
-
const candidates = ranked.filter((r) => r.agent.slug !== APP_BUILDER_ROUTE_SLUG).slice(0, ROUTE_JUDGE_CANDIDATE_CAP);
|
|
455
|
-
const bySlug = new Map(candidates.map((r) => [r.agent.slug, r]));
|
|
456
|
-
const labels = [...bySlug.keys()];
|
|
457
|
-
const hints = {};
|
|
458
|
-
const roster = [];
|
|
459
|
-
for (const r of candidates) {
|
|
460
|
-
const a = r.agent;
|
|
461
|
-
const name = [...new Set([a.name, a.nameEn].filter(Boolean))].join(" / ");
|
|
462
|
-
const tagline = [...new Set([a.tagline, a.taglineEn].filter(Boolean))].join(" / ");
|
|
463
|
-
roster.push(`- ${a.slug}: ${String(name).slice(0, 80)}${tagline ? ` — ${String(tagline).slice(0, 120)}` : ""}`);
|
|
464
|
-
// 옛 단어목록은 힌트로 강등: 큐레이션 힌트 용어 + 이 프롬프트에서 어휘 스코어러가 맞춘 용어.
|
|
465
|
-
const curated = ROUTE_HINTS.find((h) => h.slug === a.slug);
|
|
466
|
-
const hintTerms = [...new Set([...(curated ? curated.terms : []), ...(r.terms || [])])];
|
|
467
|
-
if (hintTerms.length) hints[a.slug] = hintTerms;
|
|
468
|
-
}
|
|
469
|
-
if (appBuilder) {
|
|
470
|
-
labels.push(ROUTE_JUDGE_APP_LABEL);
|
|
471
|
-
roster.push(`- ${ROUTE_JUDGE_APP_LABEL}: build a dedicated internal Agentlas App (recurring workflow, durable state, editing surfaces); explicit user consent is asked separately before anything is created`);
|
|
472
|
-
hints[ROUTE_JUDGE_APP_LABEL] = APP_BUILDER_EXPLICIT_TERMS;
|
|
113
|
+
const candidates = listRoutableAgents(db);
|
|
114
|
+
if (!candidates.length) {
|
|
115
|
+
return finish(unresolvedChoice(lang,
|
|
116
|
+
lang === "ko" ? "실행 가능한 설치 에이전트가 없습니다" : "No installed agent is available"));
|
|
473
117
|
}
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
118
|
+
const judgment = ensureJudgeRunner(db, opts.runtime || null);
|
|
119
|
+
if (!judgment || !judgment.hasJudgmentRunner()) {
|
|
120
|
+
return finish(unresolvedChoice(lang,
|
|
121
|
+
lang === "ko"
|
|
122
|
+
? "연결된 모델이 없어 요청을 맡을 에이전트를 판단하지 못했습니다"
|
|
123
|
+
: "No connected model is available to judge the request",
|
|
124
|
+
"no_runtime"));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const byLabel = new Map(candidates.map((agent) => [agent.slug, agent]));
|
|
128
|
+
const labels = [...byLabel.keys(), UNRESOLVED_LABEL];
|
|
129
|
+
const roster = candidates.map((agent) => {
|
|
130
|
+
const name = [...new Set([agent.name, agent.nameEn].filter(Boolean))].join(" / ");
|
|
131
|
+
const tagline = [...new Set([agent.tagline, agent.taglineEn].filter(Boolean))].join(" / ");
|
|
132
|
+
return `- ${agent.slug}: ${name}${tagline ? ` — ${tagline}` : ""}`;
|
|
133
|
+
});
|
|
482
134
|
const verdict = await judgment.judgeLabels({
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
question: "Which installed agent should own this user request, if any?",
|
|
135
|
+
kind: `terminal-model-route:${crypto.createHash("sha256").update(labels.join("\n")).digest("hex").slice(0, 12)}`,
|
|
136
|
+
question: "Which exact installed agent should handle this request?",
|
|
486
137
|
labels,
|
|
487
|
-
input
|
|
488
|
-
hints,
|
|
138
|
+
input,
|
|
489
139
|
guidance: [
|
|
140
|
+
"Judge the full request by meaning and the declared agent descriptions.",
|
|
141
|
+
"Do not infer fit from a shared word alone.",
|
|
142
|
+
`Choose “${UNRESOLVED_LABEL}” when no agent clearly fits or the evidence is insufficient.`,
|
|
490
143
|
"Installed agents:",
|
|
491
144
|
...roster,
|
|
492
|
-
"Mentioning a word is not intent — judge what the user actually asks to be done, in any language.",
|
|
493
|
-
`Pick "${ROUTE_JUDGE_META_LABEL}" only when the user asks to create a new agent/team/company itself.`,
|
|
494
|
-
`Pick "${ROUTE_JUDGE_DIRECT_LABEL}" when no installed agent clearly fits the request.`,
|
|
495
145
|
].join("\n"),
|
|
496
146
|
multi: false,
|
|
497
147
|
fallback: [],
|
|
498
148
|
signal: opts.signal,
|
|
499
|
-
// 라우팅은 1회성 사전 게이트 — 정확성이 지연보다 중요. 로컬 30B 모델 + 전체 로스터는
|
|
500
|
-
// 기본 20s를 넘길 수 있다. judge의 abort signal이 요청까지 전파되므로 진짜 행은 여기서 끊긴다.
|
|
501
149
|
timeoutMs: opts.timeoutMs || 40000,
|
|
502
150
|
});
|
|
503
151
|
|
|
504
|
-
// 모델이 판정을 못 냄(러너 실패/타임아웃/정크) → 어휘 픽으로 떨어지지 않는다.
|
|
505
152
|
if (verdict.source !== "llm" || !verdict.labels.length) {
|
|
506
|
-
return finish(
|
|
153
|
+
return finish(unresolvedChoice(lang,
|
|
154
|
+
lang === "ko"
|
|
155
|
+
? "연결된 모델이 유효한 판단을 반환하지 않았습니다"
|
|
156
|
+
: "The connected model did not return a valid judgment",
|
|
157
|
+
"model_unavailable"));
|
|
507
158
|
}
|
|
508
159
|
const picked = verdict.labels[0];
|
|
509
|
-
|
|
510
|
-
(lang
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
if (choice.noModel) {
|
|
529
|
-
if (choice.noModelReason === "model_unavailable") {
|
|
530
|
-
return lang === "ko" ? " (판정 없음 — 모델 응답 없음)" : " (no judgment — model did not answer)";
|
|
531
|
-
}
|
|
532
|
-
return lang === "ko" ? " (판정 없음 — 연결 모델 없음)" : " (no judgment — no model connected)";
|
|
533
|
-
}
|
|
534
|
-
return "";
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
function autoRouteNote(choice, lang) {
|
|
538
|
-
const sourceNote = routeJudgeSourceNote(choice, lang);
|
|
539
|
-
if (choice.direct || !choice.agent) {
|
|
540
|
-
return lang === "ko"
|
|
541
|
-
? `사용 에이전트: 없음 — 바로 답합니다. 이유: ${choice.reason}.${sourceNote}`
|
|
542
|
-
: `Selected agent: none — answering directly. Reason: ${choice.reason}.${sourceNote}`;
|
|
543
|
-
}
|
|
544
|
-
const name = lang === "ko" ? choice.agent.name : choice.agent.nameEn || choice.agent.name;
|
|
545
|
-
return lang === "ko"
|
|
546
|
-
? `사용 에이전트: ${name}. 이유: ${choice.reason}.${sourceNote}`
|
|
547
|
-
: `Selected agent: ${name}. Reason: ${choice.reason}.${sourceNote}`;
|
|
160
|
+
if (picked === UNRESOLVED_LABEL) {
|
|
161
|
+
return finish(unresolvedChoice(lang, verdict.reason));
|
|
162
|
+
}
|
|
163
|
+
const agent = byLabel.get(picked);
|
|
164
|
+
if (!agent) {
|
|
165
|
+
return finish(unresolvedChoice(lang,
|
|
166
|
+
lang === "ko"
|
|
167
|
+
? "판단 결과가 현재 설치된 에이전트와 일치하지 않습니다"
|
|
168
|
+
: "The judgment does not match an installed agent"));
|
|
169
|
+
}
|
|
170
|
+
const choice = {
|
|
171
|
+
agent,
|
|
172
|
+
unresolved: false,
|
|
173
|
+
routeSource: "llm",
|
|
174
|
+
reason: verdict.reason || (lang === "ko"
|
|
175
|
+
? "연결된 모델이 전체 요청과 에이전트 설명을 비교했습니다"
|
|
176
|
+
: "The connected model compared the full request with the agent descriptions"),
|
|
177
|
+
};
|
|
178
|
+
return finish(choice);
|
|
548
179
|
}
|
|
549
180
|
|
|
550
181
|
module.exports = {
|
|
551
|
-
|
|
552
|
-
META_BUILDER_SLUGS,
|
|
553
|
-
AGENT_BUILD_TERMS,
|
|
182
|
+
UNRESOLVED_LABEL,
|
|
554
183
|
resolveAutoRoute,
|
|
555
|
-
rankRouteAgents,
|
|
556
|
-
isAgentBuildIntent,
|
|
557
|
-
isTrivialRoutePrompt,
|
|
558
|
-
resolveMetaBuilder,
|
|
559
|
-
resolveDefaultRouteAgent,
|
|
560
184
|
ensureJudgeRunner,
|
|
561
185
|
autoRouteNote,
|
|
562
|
-
directSystemPrompt,
|
|
563
|
-
routeStripPaths,
|
|
564
|
-
routeTokenize,
|
|
565
186
|
};
|