@bluecopa/harness 0.1.0-snapshot.27 → 0.1.0-snapshot.29

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 (2) hide show
  1. package/package.json +1 -1
  2. package/src/arc/arc-loop.ts +66 -21
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bluecopa/harness",
3
- "version": "0.1.0-snapshot.27",
3
+ "version": "0.1.0-snapshot.29",
4
4
  "description": "Provider-agnostic TypeScript agent framework",
5
5
  "license": "UNLICENSED",
6
6
  "scripts": {
@@ -78,6 +78,8 @@ export class ArcLoop {
78
78
  private readonly traceWriter: ((event: TraceEvent) => void) | undefined;
79
79
  private readonly tracedRunning = new Set<string>();
80
80
  private readonly reportedCompletions = new Set<string>();
81
+ /** Maps normalized action text → process ID for dedup. */
82
+ private readonly actionIndex = new Map<string, string>();
81
83
  private readonly processListeners: Promise<void>[] = [];
82
84
  private readonly skillRouter: SkillRouter | undefined;
83
85
  private skillSummaries: SkillSummary[] | null = null;
@@ -266,28 +268,71 @@ export class ArcLoop {
266
268
 
267
269
  if (call.toolName === 'Thread') {
268
270
  const request = this.toProcessRequest(call.args);
269
- const proc = this.dispatch(request, signal);
270
- this.trace({ type: 'process_created', id: proc.id, status: 'pending' });
271
- const resultText = `Process ${proc.id} dispatched: "${request.action}" (model: ${request.model ?? 'medium'})`;
272
- toolResultMessages.push({
273
- role: 'tool',
274
- content: resultText,
275
- toolResults: [{
276
- toolCallId,
277
- toolName: 'Thread',
278
- result: resultText,
279
- }],
280
- });
281
- const dispatchEvent: ArcEvent = {
282
- type: 'process_dispatched',
283
- id: proc.id,
284
- action: request.action,
285
- model: proc.model,
286
- };
287
- if (request.label != null) {
288
- (dispatchEvent as { label?: string }).label = request.label;
271
+
272
+ // Require profile when processProfiles are configured
273
+ const hasProfiles = this.config.processProfiles && Object.keys(this.config.processProfiles).length > 0;
274
+ if (hasProfiles && !request.profile) {
275
+ const names = Object.keys(this.config.processProfiles!).map(n => `"${n}"`).join(', ');
276
+ const resultText = `ERROR: profile parameter is required. Available profiles: ${names}. Re-call Thread with a profile.`;
277
+ toolResultMessages.push({
278
+ role: 'tool',
279
+ content: resultText,
280
+ toolResults: [{ toolCallId, toolName: 'Thread', result: resultText }],
281
+ });
282
+ } else if (hasProfiles && request.profile && !this.config.processProfiles![request.profile]) {
283
+ const names = Object.keys(this.config.processProfiles!).map(n => `"${n}"`).join(', ');
284
+ const resultText = `ERROR: unknown profile "${request.profile}". Available profiles: ${names}. Re-call Thread with a valid profile.`;
285
+ toolResultMessages.push({
286
+ role: 'tool',
287
+ content: resultText,
288
+ toolResults: [{ toolCallId, toolName: 'Thread', result: resultText }],
289
+ });
290
+ } else {
291
+
292
+ // Dedup: reject re-dispatch of identical actions unless the previous attempt failed
293
+ const normAction = request.action.trim().replace(/\s+/g, ' ');
294
+ const existingId = this.actionIndex.get(normAction);
295
+ const existing = existingId ? this.processes.get(existingId) : undefined;
296
+
297
+ if (existing && (existing.status === 'running' || existing.status === 'pending')) {
298
+ const resultText = `DUPLICATE — thread already running for this action (process ${existing.id}). Wait for it to complete.`;
299
+ toolResultMessages.push({
300
+ role: 'tool',
301
+ content: resultText,
302
+ toolResults: [{ toolCallId, toolName: 'Thread', result: resultText }],
303
+ });
304
+ } else if (existing && existing.status === 'completed' && existing.result) {
305
+ const ep = existing.result.episode;
306
+ const resultText = `DUPLICATE — this action already completed (process ${existing.id}, episodeId: ${ep.id}). Use contextEpisodeIds: ["${ep.id}"] to reference it.`;
307
+ toolResultMessages.push({
308
+ role: 'tool',
309
+ content: resultText,
310
+ toolResults: [{ toolCallId, toolName: 'Thread', result: resultText }],
311
+ });
312
+ } else {
313
+ // New dispatch (or retry of a failed action)
314
+ const proc = this.dispatch(request, signal);
315
+ this.actionIndex.set(normAction, proc.id);
316
+ this.trace({ type: 'process_created', id: proc.id, status: 'pending' });
317
+ const resultText = `Process ${proc.id} dispatched: "${request.action}" (model: ${request.model ?? 'medium'})`;
318
+ toolResultMessages.push({
319
+ role: 'tool',
320
+ content: resultText,
321
+ toolResults: [{ toolCallId, toolName: 'Thread', result: resultText }],
322
+ });
323
+ const dispatchEvent: ArcEvent = {
324
+ type: 'process_dispatched',
325
+ id: proc.id,
326
+ action: request.action,
327
+ model: proc.model,
328
+ };
329
+ if (request.label != null) {
330
+ (dispatchEvent as { label?: string }).label = request.label;
331
+ }
332
+ yield dispatchEvent;
289
333
  }
290
- yield dispatchEvent;
334
+
335
+ } // end profile validation else
291
336
  } else if (call.toolName === 'Check') {
292
337
  const proc = this.processes.get(String(call.args.id));
293
338
  let resultText: string;