tycono 0.1.56 → 0.1.57
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/package.json +1 -1
- package/src/api/src/engine/agent-loop.ts +44 -1
- package/src/api/src/engine/authority-validator.ts +32 -1
- package/src/api/src/engine/context-assembler.ts +52 -0
- package/src/api/src/engine/index.ts +2 -2
- package/src/api/src/engine/org-tree.ts +18 -0
- package/src/api/src/engine/runners/claude-cli.ts +116 -0
- package/src/api/src/engine/runners/direct-api.ts +1 -0
- package/src/api/src/engine/runners/types.ts +1 -0
- package/src/api/src/engine/tools/definitions.ts +17 -1
- package/src/api/src/engine/tools/executor.ts +24 -1
- package/src/api/src/routes/execute.ts +3 -1
- package/src/api/src/services/company-config.ts +21 -0
- package/src/api/src/services/job-manager.ts +121 -8
- package/src/web/dist/assets/{index-Bb58vABv.js → index-CkQTzzv8.js} +13 -13
- package/src/web/dist/assets/{preview-app-8VlwyrYi.js → preview-app-IdSvrLsi.js} +1 -1
- package/src/web/dist/index.html +1 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { AnthropicProvider, type LLMProvider, type LLMMessage, type ToolResult, type MessageContent } from './llm-adapter.js';
|
|
2
2
|
import { type OrgTree, getSubordinates } from './org-tree.js';
|
|
3
3
|
import { assembleContext, type TeamStatus } from './context-assembler.js';
|
|
4
|
-
import { validateDispatch } from './authority-validator.js';
|
|
4
|
+
import { validateDispatch, validateConsult } from './authority-validator.js';
|
|
5
5
|
import { getToolsForRole } from './tools/definitions.js';
|
|
6
6
|
import { executeTool, type ToolExecutorOptions } from './tools/executor.js';
|
|
7
7
|
import { type TokenLedger } from '../services/token-ledger.js';
|
|
@@ -30,6 +30,7 @@ export interface AgentConfig {
|
|
|
30
30
|
onText?: (text: string) => void;
|
|
31
31
|
onToolExec?: (name: string, input: Record<string, unknown>) => void;
|
|
32
32
|
onDispatch?: (roleId: string, task: string) => void;
|
|
33
|
+
onConsult?: (roleId: string, question: string) => void;
|
|
33
34
|
onTurnComplete?: (turn: number) => void;
|
|
34
35
|
}
|
|
35
36
|
|
|
@@ -56,6 +57,7 @@ export async function runAgentLoop(config: AgentConfig): Promise<AgentResult> {
|
|
|
56
57
|
onText,
|
|
57
58
|
onToolExec,
|
|
58
59
|
onDispatch: onDispatchCallback,
|
|
60
|
+
onConsult: onConsultCallback,
|
|
59
61
|
onTurnComplete,
|
|
60
62
|
} = config;
|
|
61
63
|
|
|
@@ -130,6 +132,47 @@ export async function runAgentLoop(config: AgentConfig): Promise<AgentResult> {
|
|
|
130
132
|
totalInput += subResult.totalTokens.input;
|
|
131
133
|
totalOutput += subResult.totalTokens.output;
|
|
132
134
|
|
|
135
|
+
return subResult.output;
|
|
136
|
+
},
|
|
137
|
+
onConsult: async (targetRoleId: string, question: string) => {
|
|
138
|
+
// Authority check
|
|
139
|
+
const authResult = validateConsult(orgTree, roleId, targetRoleId);
|
|
140
|
+
if (!authResult.allowed) {
|
|
141
|
+
return `Consult rejected: ${authResult.reason}`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Circular consult detection
|
|
145
|
+
if (visitedRoles.has(targetRoleId)) {
|
|
146
|
+
return `[CONSULT BLOCKED] Circular consult detected: ${roleId} → ${targetRoleId}. Chain: ${[...visitedRoles].join(' → ')}`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
onConsultCallback?.(targetRoleId, question);
|
|
150
|
+
|
|
151
|
+
// Run sub-agent in read-only mode for the consulted role
|
|
152
|
+
const consultTask = `[Consultation from ${roleId}] ${question}\n\nAnswer this question based on your role's expertise and knowledge. Be concise and specific.`;
|
|
153
|
+
const subResult = await runAgentLoop({
|
|
154
|
+
companyRoot,
|
|
155
|
+
roleId: targetRoleId,
|
|
156
|
+
task: consultTask,
|
|
157
|
+
sourceRole: roleId,
|
|
158
|
+
orgTree,
|
|
159
|
+
readOnly: true, // Consult is always read-only
|
|
160
|
+
maxTurns: Math.min(maxTurns, 10), // Limit consult turns
|
|
161
|
+
llm,
|
|
162
|
+
depth: depth + 1,
|
|
163
|
+
visitedRoles: new Set(visitedRoles),
|
|
164
|
+
abortSignal,
|
|
165
|
+
jobId: config.jobId,
|
|
166
|
+
model: config.model,
|
|
167
|
+
tokenLedger: config.tokenLedger,
|
|
168
|
+
onText: (text) => onText?.(`[consult:${targetRoleId}] ${text}`),
|
|
169
|
+
onToolExec,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// Aggregate sub-agent tokens
|
|
173
|
+
totalInput += subResult.totalTokens.input;
|
|
174
|
+
totalOutput += subResult.totalTokens.output;
|
|
175
|
+
|
|
133
176
|
return subResult.output;
|
|
134
177
|
},
|
|
135
178
|
};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type OrgTree, canDispatchTo, getChainOfCommand } from './org-tree.js';
|
|
1
|
+
import { type OrgTree, canDispatchTo, canConsult, getChainOfCommand } from './org-tree.js';
|
|
2
2
|
|
|
3
3
|
/* ─── Types ──────────────────────────────────── */
|
|
4
4
|
|
|
@@ -47,6 +47,37 @@ export function validateDispatch(
|
|
|
47
47
|
return { allowed: true, reason: 'Dispatch authorized' };
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Validate whether a source role can consult (ask a question to) a target role.
|
|
52
|
+
* Allowed: peers (same parent), direct manager, or subordinates.
|
|
53
|
+
*/
|
|
54
|
+
export function validateConsult(
|
|
55
|
+
orgTree: OrgTree,
|
|
56
|
+
sourceRole: string,
|
|
57
|
+
targetRole: string,
|
|
58
|
+
): AuthResult {
|
|
59
|
+
if (sourceRole === targetRole) {
|
|
60
|
+
return { allowed: false, reason: `Cannot consult self (${sourceRole})` };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (!orgTree.nodes.has(sourceRole) && sourceRole !== 'ceo') {
|
|
64
|
+
return { allowed: false, reason: `Source role not found: ${sourceRole}` };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!orgTree.nodes.has(targetRole)) {
|
|
68
|
+
return { allowed: false, reason: `Target role not found: ${targetRole}` };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (!canConsult(orgTree, sourceRole, targetRole)) {
|
|
72
|
+
return {
|
|
73
|
+
allowed: false,
|
|
74
|
+
reason: `${sourceRole} cannot consult ${targetRole}. Only peers (same manager), direct manager, or subordinates are allowed.`,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return { allowed: true, reason: 'Consult authorized' };
|
|
79
|
+
}
|
|
80
|
+
|
|
50
81
|
/**
|
|
51
82
|
* Validate whether a role can perform a write operation to a given path.
|
|
52
83
|
* Checks the knowledge.writes scope from role.yaml.
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
getSubordinates,
|
|
9
9
|
getChainOfCommand,
|
|
10
10
|
formatOrgChart,
|
|
11
|
+
canConsult,
|
|
11
12
|
} from './org-tree.js';
|
|
12
13
|
|
|
13
14
|
/* ─── Types ──────────────────────────────────── */
|
|
@@ -113,6 +114,12 @@ export function assembleContext(
|
|
|
113
114
|
sections.push(buildDispatchSection(orgTree, roleId, subordinates, options?.teamStatus));
|
|
114
115
|
}
|
|
115
116
|
|
|
117
|
+
// Consult 도구 안내 (상담 가능한 Role이 있는 경우)
|
|
118
|
+
const consultSection = buildConsultSection(orgTree, roleId);
|
|
119
|
+
if (consultSection) {
|
|
120
|
+
sections.push(consultSection);
|
|
121
|
+
}
|
|
122
|
+
|
|
116
123
|
// Language preference
|
|
117
124
|
const prefs = readPreferences(companyRoot);
|
|
118
125
|
const lang = prefs.language ?? 'auto';
|
|
@@ -514,3 +521,48 @@ Every dispatch MUST include:
|
|
|
514
521
|
|
|
515
522
|
return section;
|
|
516
523
|
}
|
|
524
|
+
|
|
525
|
+
function buildConsultSection(orgTree: OrgTree, roleId: string): string | null {
|
|
526
|
+
// Build list of roles this agent can consult
|
|
527
|
+
const consultable: string[] = [];
|
|
528
|
+
for (const [id] of orgTree.nodes) {
|
|
529
|
+
if (id !== roleId && canConsult(orgTree, roleId, id)) {
|
|
530
|
+
consultable.push(id);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
if (consultable.length === 0) return null;
|
|
535
|
+
|
|
536
|
+
const roleList = consultable.map((id) => {
|
|
537
|
+
const n = orgTree.nodes.get(id);
|
|
538
|
+
if (!n) return `- \`${id}\``;
|
|
539
|
+
const firstLine = n.persona.split('\n')[0] || n.name;
|
|
540
|
+
return `- **${n.name}** (\`${id}\`): ${firstLine}`;
|
|
541
|
+
}).join('\n');
|
|
542
|
+
|
|
543
|
+
return `# Consult (Ask Colleagues)
|
|
544
|
+
|
|
545
|
+
You can ask questions to other roles using the \`consult\` tool:
|
|
546
|
+
|
|
547
|
+
${roleList}
|
|
548
|
+
|
|
549
|
+
## How to Consult
|
|
550
|
+
|
|
551
|
+
Use the \`consult\` tool:
|
|
552
|
+
\`\`\`json
|
|
553
|
+
{ "roleId": "designer", "question": "What color scheme are you using for the dashboard?" }
|
|
554
|
+
\`\`\`
|
|
555
|
+
|
|
556
|
+
The consulted role will answer your question in read-only mode and return the response to you.
|
|
557
|
+
|
|
558
|
+
## When to Use
|
|
559
|
+
- Need technical decisions or clarifications from your manager
|
|
560
|
+
- Need design/implementation details from a peer
|
|
561
|
+
- Need domain expertise from another team member
|
|
562
|
+
- Unsure about architecture or conventions — ask before guessing
|
|
563
|
+
|
|
564
|
+
## Rules
|
|
565
|
+
- The consulted role answers in **read-only mode** (no file modifications)
|
|
566
|
+
- Keep questions specific and concise for better answers
|
|
567
|
+
- Don't consult for tasks that should be dispatched (use dispatch for work assignments)`;
|
|
568
|
+
}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
// Context Engine — public API
|
|
2
|
-
export { buildOrgTree, canDispatchTo, getSubordinates, getDescendants, getChainOfCommand, formatOrgChart, refreshOrgTree } from './org-tree.js';
|
|
2
|
+
export { buildOrgTree, canDispatchTo, canConsult, getSubordinates, getDescendants, getChainOfCommand, formatOrgChart, refreshOrgTree } from './org-tree.js';
|
|
3
3
|
export type { OrgTree, OrgNode, Authority, KnowledgeAccess } from './org-tree.js';
|
|
4
4
|
|
|
5
5
|
export { assembleContext } from './context-assembler.js';
|
|
6
6
|
export type { AssembledContext } from './context-assembler.js';
|
|
7
7
|
|
|
8
|
-
export { validateDispatch, validateWrite, validateRead } from './authority-validator.js';
|
|
8
|
+
export { validateDispatch, validateConsult, validateWrite, validateRead } from './authority-validator.js';
|
|
9
9
|
export type { AuthResult } from './authority-validator.js';
|
|
10
10
|
|
|
11
11
|
export { RoleLifecycleManager } from './role-lifecycle.js';
|
|
@@ -195,6 +195,24 @@ export function canDispatchTo(tree: OrgTree, source: string, target: string): bo
|
|
|
195
195
|
return descendants.includes(target);
|
|
196
196
|
}
|
|
197
197
|
|
|
198
|
+
/** Can source consult (ask a question to) target? Peers, direct manager, or subordinates. */
|
|
199
|
+
export function canConsult(tree: OrgTree, source: string, target: string): boolean {
|
|
200
|
+
if (source === target) return false;
|
|
201
|
+
const sourceNode = tree.nodes.get(source);
|
|
202
|
+
const targetNode = tree.nodes.get(target);
|
|
203
|
+
if (!sourceNode || !targetNode) return false;
|
|
204
|
+
|
|
205
|
+
// 1. Peers — same parent
|
|
206
|
+
if (sourceNode.reportsTo === targetNode.reportsTo) return true;
|
|
207
|
+
|
|
208
|
+
// 2. Direct manager
|
|
209
|
+
if (sourceNode.reportsTo === target) return true;
|
|
210
|
+
|
|
211
|
+
// 3. Subordinates (same as dispatch scope)
|
|
212
|
+
const descendants = getDescendants(tree, source);
|
|
213
|
+
return descendants.includes(target);
|
|
214
|
+
}
|
|
215
|
+
|
|
198
216
|
/** Refresh tree (re-read all role.yaml files) */
|
|
199
217
|
export function refreshOrgTree(companyRoot: string): OrgTree {
|
|
200
218
|
return buildOrgTree(companyRoot);
|
|
@@ -116,6 +116,113 @@ else:
|
|
|
116
116
|
log(f'Check result later: python3 "$DISPATCH_CMD" --check {job_id}')
|
|
117
117
|
`;
|
|
118
118
|
|
|
119
|
+
/* ─── Consult Bridge Script (Python3) ────── */
|
|
120
|
+
|
|
121
|
+
const CONSULT_SCRIPT = `#!/usr/bin/env python3
|
|
122
|
+
"""consult-bridge: CLI runner가 다른 Role에게 질문하는 브릿지 스크립트.
|
|
123
|
+
|
|
124
|
+
사용법:
|
|
125
|
+
consult <roleId> "<question>" — Job 시작 (readOnly) + 결과 대기
|
|
126
|
+
consult --check <jobId> — 완료된 Job 결과 조회
|
|
127
|
+
|
|
128
|
+
환경변수:
|
|
129
|
+
CONSULT_API_URL — API 서버 URL (default: http://localhost:3001)
|
|
130
|
+
CONSULT_PARENT_JOB — 부모 Job ID (자동 설정)
|
|
131
|
+
CONSULT_SOURCE_ROLE — 현재 Role ID (자동 설정)
|
|
132
|
+
"""
|
|
133
|
+
import sys, os, json, time, urllib.request, urllib.error
|
|
134
|
+
sys.stdout.reconfigure(line_buffering=True)
|
|
135
|
+
|
|
136
|
+
api = os.environ.get('CONSULT_API_URL', os.environ.get('DISPATCH_API_URL', 'http://localhost:3001'))
|
|
137
|
+
|
|
138
|
+
def log(msg):
|
|
139
|
+
print(msg, flush=True)
|
|
140
|
+
|
|
141
|
+
def get_result(job_id):
|
|
142
|
+
try:
|
|
143
|
+
history = json.loads(urllib.request.urlopen(f'{api}/api/jobs/{job_id}/history', timeout=10).read())
|
|
144
|
+
events = history.get('events', [])
|
|
145
|
+
text_parts = []
|
|
146
|
+
for e in events:
|
|
147
|
+
if e['type'] == 'text':
|
|
148
|
+
text_parts.append(e['data'].get('text', ''))
|
|
149
|
+
elif e['type'] == 'job:error':
|
|
150
|
+
text_parts.append('\\nERROR: ' + e['data'].get('message', ''))
|
|
151
|
+
return ''.join(text_parts) or '(No text output)'
|
|
152
|
+
except Exception as e:
|
|
153
|
+
return f'ERROR: Failed to get result: {e}'
|
|
154
|
+
|
|
155
|
+
# Mode: --check <jobId>
|
|
156
|
+
if len(sys.argv) >= 3 and sys.argv[1] == '--check':
|
|
157
|
+
job_id = sys.argv[2]
|
|
158
|
+
try:
|
|
159
|
+
info = json.loads(urllib.request.urlopen(f'{api}/api/jobs/{job_id}', timeout=10).read())
|
|
160
|
+
status = info.get('status', 'unknown')
|
|
161
|
+
if status == 'running':
|
|
162
|
+
log(f'Job {job_id} is still running. Try again later.')
|
|
163
|
+
else:
|
|
164
|
+
log(f'=== Job {job_id}: {status} ===')
|
|
165
|
+
log(get_result(job_id))
|
|
166
|
+
except Exception as e:
|
|
167
|
+
log(f'ERROR: {e}')
|
|
168
|
+
sys.exit(0)
|
|
169
|
+
|
|
170
|
+
# Mode: consult <roleId> "<question>"
|
|
171
|
+
if len(sys.argv) < 3:
|
|
172
|
+
log('Usage: consult <roleId> "<question>"')
|
|
173
|
+
log(' consult --check <jobId>')
|
|
174
|
+
sys.exit(1)
|
|
175
|
+
|
|
176
|
+
role_id = sys.argv[1]
|
|
177
|
+
question = ' '.join(sys.argv[2:])
|
|
178
|
+
parent_job = os.environ.get('CONSULT_PARENT_JOB', os.environ.get('DISPATCH_PARENT_JOB', ''))
|
|
179
|
+
source_role = os.environ.get('CONSULT_SOURCE_ROLE', os.environ.get('DISPATCH_SOURCE_ROLE', 'ceo'))
|
|
180
|
+
|
|
181
|
+
# Start job (readOnly + consult type)
|
|
182
|
+
task = f'[Consultation from {source_role}] {question}\\n\\nAnswer this question based on your role\\'s expertise and knowledge. Be concise and specific.'
|
|
183
|
+
body = json.dumps({
|
|
184
|
+
'type': 'consult',
|
|
185
|
+
'roleId': role_id,
|
|
186
|
+
'task': task,
|
|
187
|
+
'sourceRole': source_role,
|
|
188
|
+
'readOnly': True,
|
|
189
|
+
'parentJobId': parent_job if parent_job else None,
|
|
190
|
+
}).encode()
|
|
191
|
+
|
|
192
|
+
try:
|
|
193
|
+
req = urllib.request.Request(f'{api}/api/jobs', body, {'Content-Type': 'application/json'})
|
|
194
|
+
resp = json.loads(urllib.request.urlopen(req, timeout=10).read())
|
|
195
|
+
job_id = resp['jobId']
|
|
196
|
+
except Exception as e:
|
|
197
|
+
log(f'ERROR: Failed to start consult job: {e}')
|
|
198
|
+
sys.exit(1)
|
|
199
|
+
|
|
200
|
+
log(f'=== Consulting {role_id.upper()} ===')
|
|
201
|
+
log(f'Question: {question[:120]}')
|
|
202
|
+
log(f'Job ID: {job_id}')
|
|
203
|
+
|
|
204
|
+
# Wait for completion (max ~100s)
|
|
205
|
+
status = 'running'
|
|
206
|
+
waited = 0
|
|
207
|
+
while waited < 100:
|
|
208
|
+
try:
|
|
209
|
+
info = json.loads(urllib.request.urlopen(f'{api}/api/jobs/{job_id}', timeout=5).read())
|
|
210
|
+
status = info.get('status', 'unknown')
|
|
211
|
+
if status in ('done', 'error'):
|
|
212
|
+
break
|
|
213
|
+
except Exception:
|
|
214
|
+
pass
|
|
215
|
+
time.sleep(3)
|
|
216
|
+
waited += 3
|
|
217
|
+
|
|
218
|
+
if status in ('done', 'error'):
|
|
219
|
+
log(f'\\n=== {role_id.upper()} Answer ({status}) ===')
|
|
220
|
+
log(get_result(job_id))
|
|
221
|
+
else:
|
|
222
|
+
log(f'\\n{role_id.upper()} is still thinking (waited {waited}s).')
|
|
223
|
+
log(f'Check result later: python3 "$CONSULT_CMD" --check {job_id}')
|
|
224
|
+
`;
|
|
225
|
+
|
|
119
226
|
/* ─── Claude CLI Runner ──────────────────────── */
|
|
120
227
|
|
|
121
228
|
/**
|
|
@@ -163,6 +270,10 @@ export class ClaudeCliRunner implements ExecutionRunner {
|
|
|
163
270
|
fs.writeFileSync(dispatchScript, DISPATCH_SCRIPT, { mode: 0o755 });
|
|
164
271
|
}
|
|
165
272
|
|
|
273
|
+
// Consult Bridge — available to ALL roles (not just managers)
|
|
274
|
+
const consultScript = path.join(tmpDir, `consult-${roleId}-${Date.now()}.py`);
|
|
275
|
+
fs.writeFileSync(consultScript, CONSULT_SCRIPT, { mode: 0o755 });
|
|
276
|
+
|
|
166
277
|
// 5. Playwright MCP 설정 — 각 runner 인스턴스가 독립 브라우저 사용
|
|
167
278
|
const runnerOutputDir = path.join(tmpDir, `playwright-${roleId}-${Date.now()}`);
|
|
168
279
|
fs.mkdirSync(runnerOutputDir, { recursive: true });
|
|
@@ -207,6 +318,8 @@ export class ClaudeCliRunner implements ExecutionRunner {
|
|
|
207
318
|
}
|
|
208
319
|
// dispatch 명령어 경로를 PATH에 추가하지 않고 절대 경로로 사용
|
|
209
320
|
cleanEnv.DISPATCH_CMD = dispatchScript;
|
|
321
|
+
cleanEnv.CONSULT_CMD = consultScript;
|
|
322
|
+
cleanEnv.CONSULT_SOURCE_ROLE = roleId;
|
|
210
323
|
|
|
211
324
|
const modelName = config.model ?? 'claude-sonnet-4-5';
|
|
212
325
|
// Use codeRoot as cwd if configured, otherwise fall back to companyRoot
|
|
@@ -245,6 +358,7 @@ export class ClaudeCliRunner implements ExecutionRunner {
|
|
|
245
358
|
resolved = true;
|
|
246
359
|
try { fs.unlinkSync(promptFile); } catch { /* ignore */ }
|
|
247
360
|
try { fs.unlinkSync(dispatchScript); } catch { /* ignore */ }
|
|
361
|
+
try { fs.unlinkSync(consultScript); } catch { /* ignore */ }
|
|
248
362
|
try { fs.rmSync(runnerOutputDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
249
363
|
resolve({
|
|
250
364
|
output,
|
|
@@ -339,6 +453,7 @@ export class ClaudeCliRunner implements ExecutionRunner {
|
|
|
339
453
|
// 임시 파일 정리
|
|
340
454
|
try { fs.unlinkSync(promptFile); } catch { /* ignore */ }
|
|
341
455
|
try { fs.unlinkSync(dispatchScript); } catch { /* ignore */ }
|
|
456
|
+
try { fs.unlinkSync(consultScript); } catch { /* ignore */ }
|
|
342
457
|
try { fs.rmSync(runnerOutputDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
343
458
|
|
|
344
459
|
// 비정상 종료 시에도 결과 반환 (output이 있을 수 있으므로)
|
|
@@ -356,6 +471,7 @@ export class ClaudeCliRunner implements ExecutionRunner {
|
|
|
356
471
|
resolved = true;
|
|
357
472
|
try { fs.unlinkSync(promptFile); } catch { /* ignore */ }
|
|
358
473
|
try { fs.unlinkSync(dispatchScript); } catch { /* ignore */ }
|
|
474
|
+
try { fs.unlinkSync(consultScript); } catch { /* ignore */ }
|
|
359
475
|
try { fs.rmSync(runnerOutputDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
360
476
|
reject(err);
|
|
361
477
|
});
|
|
@@ -44,6 +44,7 @@ export class DirectApiRunner implements ExecutionRunner {
|
|
|
44
44
|
onText: (text) => callbacks.onText?.(text),
|
|
45
45
|
onToolExec: (name, input) => callbacks.onToolUse?.(name, input),
|
|
46
46
|
onDispatch: (roleId, task) => callbacks.onDispatch?.(roleId, task),
|
|
47
|
+
onConsult: (roleId, question) => callbacks.onConsult?.(roleId, question),
|
|
47
48
|
onTurnComplete: (turn) => callbacks.onTurnComplete?.(turn),
|
|
48
49
|
}).then((agentResult): RunnerResult => ({
|
|
49
50
|
output: agentResult.output,
|
|
@@ -46,6 +46,7 @@ export interface RunnerCallbacks {
|
|
|
46
46
|
onThinking?: (text: string) => void;
|
|
47
47
|
onToolUse?: (tool: string, input?: Record<string, unknown>) => void;
|
|
48
48
|
onDispatch?: (roleId: string, task: string) => void;
|
|
49
|
+
onConsult?: (roleId: string, question: string) => void;
|
|
49
50
|
onTurnComplete?: (turn: number) => void;
|
|
50
51
|
onError?: (error: string) => void;
|
|
51
52
|
}
|
|
@@ -89,6 +89,22 @@ export const DISPATCH_TOOL: ToolDefinition = {
|
|
|
89
89
|
},
|
|
90
90
|
};
|
|
91
91
|
|
|
92
|
+
/**
|
|
93
|
+
* 상담 도구 — 모든 Role에게 제공 (동료/상관/부하에게 질문)
|
|
94
|
+
*/
|
|
95
|
+
export const CONSULT_TOOL: ToolDefinition = {
|
|
96
|
+
name: 'consult',
|
|
97
|
+
description: 'Ask a question to another role (peer, manager, or subordinate) and wait for their answer. The consulted role will respond in read-only mode. Use when you need information, expertise, or a decision from a colleague.',
|
|
98
|
+
input_schema: {
|
|
99
|
+
type: 'object',
|
|
100
|
+
properties: {
|
|
101
|
+
roleId: { type: 'string', description: 'Target role ID to consult (e.g., "designer", "cto")' },
|
|
102
|
+
question: { type: 'string', description: 'The question to ask' },
|
|
103
|
+
},
|
|
104
|
+
required: ['roleId', 'question'],
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
|
|
92
108
|
/**
|
|
93
109
|
* Role에 따른 도구 목록 반환
|
|
94
110
|
*/
|
|
@@ -97,7 +113,7 @@ export function getToolsForRole(hasSubordinates: boolean, readOnly: boolean): To
|
|
|
97
113
|
return [...READ_TOOLS];
|
|
98
114
|
}
|
|
99
115
|
|
|
100
|
-
const tools = [...READ_TOOLS, ...WRITE_TOOLS];
|
|
116
|
+
const tools = [...READ_TOOLS, ...WRITE_TOOLS, CONSULT_TOOL];
|
|
101
117
|
|
|
102
118
|
if (hasSubordinates) {
|
|
103
119
|
tools.push(DISPATCH_TOOL);
|
|
@@ -12,6 +12,7 @@ export interface ToolExecutorOptions {
|
|
|
12
12
|
roleId: string;
|
|
13
13
|
orgTree: OrgTree;
|
|
14
14
|
onDispatch?: (roleId: string, task: string) => Promise<string>;
|
|
15
|
+
onConsult?: (roleId: string, question: string) => Promise<string>;
|
|
15
16
|
onToolExec?: (name: string, input: Record<string, unknown>) => void;
|
|
16
17
|
}
|
|
17
18
|
|
|
@@ -21,7 +22,7 @@ export async function executeTool(
|
|
|
21
22
|
toolCall: ToolCall,
|
|
22
23
|
options: ToolExecutorOptions,
|
|
23
24
|
): Promise<ToolResult> {
|
|
24
|
-
const { companyRoot, roleId, orgTree, onDispatch, onToolExec } = options;
|
|
25
|
+
const { companyRoot, roleId, orgTree, onDispatch, onConsult, onToolExec } = options;
|
|
25
26
|
const { id, name, input } = toolCall;
|
|
26
27
|
|
|
27
28
|
onToolExec?.(name, input);
|
|
@@ -40,6 +41,8 @@ export async function executeTool(
|
|
|
40
41
|
return editFile(id, input, companyRoot, roleId, orgTree);
|
|
41
42
|
case 'dispatch':
|
|
42
43
|
return await dispatchTask(id, input, onDispatch);
|
|
44
|
+
case 'consult':
|
|
45
|
+
return await consultTask(id, input, onConsult);
|
|
43
46
|
default:
|
|
44
47
|
return { tool_use_id: id, content: `Unknown tool: ${name}`, is_error: true };
|
|
45
48
|
}
|
|
@@ -286,3 +289,23 @@ async function dispatchTask(
|
|
|
286
289
|
const result = await onDispatch(roleId, task);
|
|
287
290
|
return { tool_use_id: id, content: result };
|
|
288
291
|
}
|
|
292
|
+
|
|
293
|
+
async function consultTask(
|
|
294
|
+
id: string,
|
|
295
|
+
input: Record<string, unknown>,
|
|
296
|
+
onConsult?: (roleId: string, question: string) => Promise<string>,
|
|
297
|
+
): Promise<ToolResult> {
|
|
298
|
+
const roleId = String(input.roleId ?? '');
|
|
299
|
+
const question = String(input.question ?? '');
|
|
300
|
+
|
|
301
|
+
if (!roleId || !question) {
|
|
302
|
+
return { tool_use_id: id, content: 'Error: roleId and question are required', is_error: true };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (!onConsult) {
|
|
306
|
+
return { tool_use_id: id, content: 'Error: consult not available in this context', is_error: true };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const result = await onConsult(roleId, question);
|
|
310
|
+
return { tool_use_id: id, content: result };
|
|
311
|
+
}
|
|
@@ -292,7 +292,9 @@ function handleReplyToJob(jobId: string, body: Record<string, unknown>, res: Ser
|
|
|
292
292
|
return;
|
|
293
293
|
}
|
|
294
294
|
|
|
295
|
-
const
|
|
295
|
+
const responderRole = body.responderRole as string | undefined;
|
|
296
|
+
|
|
297
|
+
const newJob = jobManager.replyToJob(jobId, message, responderRole);
|
|
296
298
|
if (!newJob) {
|
|
297
299
|
jsonResponse(res, 400, { error: 'Job not found or not awaiting input' });
|
|
298
300
|
return;
|
|
@@ -7,18 +7,39 @@
|
|
|
7
7
|
import fs from 'node:fs';
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
|
|
10
|
+
export interface ConversationLimits {
|
|
11
|
+
/** Harness 레벨 경고 턴 수 (기본 50). 도달 시 turn:warning 이벤트 발생. */
|
|
12
|
+
softLimit: number;
|
|
13
|
+
/** Harness 레벨 강제 종료 턴 수 (기본 200). 도달 시 Runner abort. */
|
|
14
|
+
hardLimit: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
10
17
|
export interface CompanyConfig {
|
|
11
18
|
engine: 'claude-cli' | 'direct-api';
|
|
12
19
|
model?: string;
|
|
13
20
|
apiKey?: string;
|
|
14
21
|
codeRoot?: string; // 코드 프로젝트 경로 (AKB와 분리된 코드 repo)
|
|
22
|
+
conversationLimits?: Partial<ConversationLimits>;
|
|
15
23
|
}
|
|
16
24
|
|
|
17
25
|
export const TYCONO_DIR = '.tycono';
|
|
18
26
|
const CONFIG_DIR = TYCONO_DIR;
|
|
19
27
|
const CONFIG_FILE = 'config.json';
|
|
28
|
+
const DEFAULT_CONVERSATION_LIMITS: ConversationLimits = {
|
|
29
|
+
softLimit: 50,
|
|
30
|
+
hardLimit: 200,
|
|
31
|
+
};
|
|
32
|
+
|
|
20
33
|
const DEFAULT_CONFIG: CompanyConfig = { engine: 'claude-cli' };
|
|
21
34
|
|
|
35
|
+
/** Resolve conversation limits with defaults. */
|
|
36
|
+
export function getConversationLimits(config: CompanyConfig): ConversationLimits {
|
|
37
|
+
return {
|
|
38
|
+
...DEFAULT_CONVERSATION_LIMITS,
|
|
39
|
+
...config.conversationLimits,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
22
43
|
function configPath(companyRoot: string): string {
|
|
23
44
|
return path.join(companyRoot, CONFIG_DIR, CONFIG_FILE);
|
|
24
45
|
}
|