Skip to content

Commit a9061f0

Browse files
Merge pull request #9 from Agent-Field/santosh/docker-cleanup-callback
Merging after local Go/Python test run (CI bypass approved).
2 parents d2cc225 + f5bc74c commit a9061f0

18 files changed

Lines changed: 1305 additions & 89 deletions

File tree

control-plane/internal/handlers/execute.go

Lines changed: 143 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,83 @@ func (c *executionController) handleSync(ctx *gin.Context) {
201201
}
202202

203203
resultBody, elapsed, asyncAccepted, callErr := c.callAgent(reqCtx, plan)
204+
205+
// If agent returned HTTP 202 (async acknowledgment), wait for callback completion
206+
if callErr == nil && asyncAccepted {
207+
logger.Logger.Info().
208+
Str("execution_id", plan.exec.ExecutionID).
209+
Str("agent", plan.target.NodeID).
210+
Str("reasoner", plan.target.TargetName).
211+
Msg("agent returned async acknowledgment, waiting for completion")
212+
213+
// Wait for agent to call back and complete the execution
214+
// Use 90 second timeout to match the HTTP client timeout
215+
exec, waitErr := c.waitForExecutionCompletion(reqCtx, plan.exec.ExecutionID, 90*time.Second)
216+
if waitErr != nil {
217+
logger.Logger.Error().
218+
Err(waitErr).
219+
Str("execution_id", plan.exec.ExecutionID).
220+
Msg("failed to wait for async execution completion")
221+
writeExecutionError(ctx, waitErr)
222+
return
223+
}
224+
225+
// Build response from completed execution
226+
var result interface{}
227+
if exec.ResultPayload != nil {
228+
result = decodeJSON(exec.ResultPayload)
229+
}
230+
231+
var durationMS int64
232+
if exec.DurationMS != nil {
233+
durationMS = *exec.DurationMS
234+
}
235+
236+
var finishedAt string
237+
if exec.CompletedAt != nil {
238+
finishedAt = exec.CompletedAt.UTC().Format(time.RFC3339)
239+
} else {
240+
finishedAt = time.Now().UTC().Format(time.RFC3339)
241+
}
242+
243+
// Check if execution failed
244+
if exec.Status == types.ExecutionStatusFailed {
245+
errMsg := "execution failed"
246+
if exec.ErrorMessage != nil {
247+
errMsg = *exec.ErrorMessage
248+
}
249+
response := ExecuteResponse{
250+
ExecutionID: exec.ExecutionID,
251+
RunID: exec.RunID,
252+
Status: string(exec.Status),
253+
ErrorMessage: &errMsg,
254+
DurationMS: durationMS,
255+
FinishedAt: finishedAt,
256+
WebhookRegistered: exec.WebhookRegistered,
257+
}
258+
ctx.Header("X-Execution-ID", exec.ExecutionID)
259+
ctx.Header("X-Run-ID", exec.RunID)
260+
ctx.JSON(http.StatusOK, response)
261+
return
262+
}
263+
264+
// Return successful execution result
265+
response := ExecuteResponse{
266+
ExecutionID: exec.ExecutionID,
267+
RunID: exec.RunID,
268+
Status: string(exec.Status),
269+
Result: result,
270+
DurationMS: durationMS,
271+
FinishedAt: finishedAt,
272+
WebhookRegistered: exec.WebhookRegistered,
273+
}
274+
ctx.Header("X-Execution-ID", exec.ExecutionID)
275+
ctx.Header("X-Run-ID", exec.RunID)
276+
ctx.JSON(http.StatusOK, response)
277+
return
278+
}
279+
280+
// Agent returned HTTP 200 (synchronous result), process completion normally
204281
job := completionJob{
205282
controller: c,
206283
plan: plan,
@@ -209,26 +286,6 @@ func (c *executionController) handleSync(ctx *gin.Context) {
209286
callErr: callErr,
210287
done: make(chan error, 1),
211288
}
212-
if callErr == nil && asyncAccepted {
213-
response := AsyncExecuteResponse{
214-
ExecutionID: plan.exec.ExecutionID,
215-
RunID: plan.exec.RunID,
216-
WorkflowID: plan.exec.RunID,
217-
Status: string(types.ExecutionStatusRunning),
218-
Target: fmt.Sprintf("%s.%s", plan.target.NodeID, plan.target.TargetName),
219-
Type: plan.targetType,
220-
CreatedAt: plan.exec.CreatedAt.UTC().Format(time.RFC3339),
221-
EnqueuedAt: plan.exec.CreatedAt.UTC().Format(time.RFC3339),
222-
WebhookRegistered: plan.webhookRegistered,
223-
}
224-
if plan.webhookError != nil {
225-
response.WebhookError = plan.webhookError
226-
}
227-
ctx.Header("X-Execution-ID", plan.exec.ExecutionID)
228-
ctx.Header("X-Run-ID", plan.exec.RunID)
229-
ctx.JSON(http.StatusAccepted, response)
230-
return
231-
}
232289
if err := enqueueCompletion(job); err != nil {
233290
logger.Logger.Error().Err(err).Str("execution_id", plan.exec.ExecutionID).Msg("failed to enqueue completion job")
234291
writeExecutionError(ctx, err)
@@ -505,6 +562,72 @@ func (c *executionController) publishExecutionEvent(exec *types.Execution, statu
505562
c.eventBus.Publish(event)
506563
}
507564

565+
// waitForExecutionCompletion waits for an execution to complete by subscribing to the event bus.
566+
// It returns the completed execution record or an error if the execution fails or times out.
567+
// This is used when agents return HTTP 202 (async acknowledgment) but the sync endpoint needs to wait for completion.
568+
func (c *executionController) waitForExecutionCompletion(ctx context.Context, executionID string, timeout time.Duration) (*types.Execution, error) {
569+
if c.eventBus == nil {
570+
return nil, fmt.Errorf("event bus not available")
571+
}
572+
573+
// Create unique subscriber ID for this wait operation
574+
subscriberID := fmt.Sprintf("sync-wait-%s", executionID)
575+
576+
// Subscribe to events
577+
eventChan := c.eventBus.Subscribe(subscriberID)
578+
defer c.eventBus.Unsubscribe(subscriberID)
579+
580+
// Create timeout timer
581+
timer := time.NewTimer(timeout)
582+
defer timer.Stop()
583+
584+
logger.Logger.Debug().
585+
Str("execution_id", executionID).
586+
Dur("timeout", timeout).
587+
Msg("waiting for execution completion via event bus")
588+
589+
for {
590+
select {
591+
case <-ctx.Done():
592+
return nil, ctx.Err()
593+
594+
case <-timer.C:
595+
logger.Logger.Warn().
596+
Str("execution_id", executionID).
597+
Dur("timeout", timeout).
598+
Msg("execution completion timeout")
599+
return nil, fmt.Errorf("execution timeout after %v", timeout)
600+
601+
case event := <-eventChan:
602+
// Only process events for this specific execution
603+
if event.ExecutionID != executionID {
604+
continue
605+
}
606+
607+
// Check if this is a terminal event
608+
if event.Type == events.ExecutionCompleted || event.Type == events.ExecutionFailed {
609+
logger.Logger.Debug().
610+
Str("execution_id", executionID).
611+
Str("event_type", string(event.Type)).
612+
Msg("received terminal execution event")
613+
614+
// Fetch the updated execution record
615+
exec, err := c.store.GetExecutionRecord(ctx, executionID)
616+
if err != nil {
617+
return nil, fmt.Errorf("failed to fetch execution after completion: %w", err)
618+
}
619+
if exec == nil {
620+
return nil, fmt.Errorf("execution %s not found after completion event", executionID)
621+
}
622+
623+
return exec, nil
624+
}
625+
626+
// Continue waiting for other event types (ExecutionUpdated, etc.)
627+
}
628+
}
629+
}
630+
508631
type preparedExecution struct {
509632
exec *types.Execution
510633
requestBody []byte

control-plane/internal/handlers/nodes.go

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -394,7 +394,28 @@ func RegisterNodeHandler(storageProvider storage.StorageProvider, uiService *ser
394394
var normalizedCandidates []string
395395
var probeResults []types.CallbackTestResult
396396

397-
if len(candidateList) > 0 {
397+
// Determine if auto-discovery should be skipped
398+
// Skip auto-discovery if:
399+
// 1. An explicit BaseURL was provided by the agent AND
400+
// 2. Either no discovery mode is set OR mode is explicitly "manual"/"explicit"
401+
skipAutoDiscovery := false
402+
if newNode.BaseURL != "" {
403+
// If callback discovery mode is explicitly set to manual/explicit, respect it
404+
if newNode.CallbackDiscovery != nil &&
405+
(newNode.CallbackDiscovery.Mode == "manual" || newNode.CallbackDiscovery.Mode == "explicit") {
406+
skipAutoDiscovery = true
407+
logger.Logger.Info().Msgf("✅ Using explicit callback URL for %s (mode=%s): %s",
408+
newNode.ID, newNode.CallbackDiscovery.Mode, newNode.BaseURL)
409+
} else if newNode.CallbackDiscovery == nil || newNode.CallbackDiscovery.Mode == "" {
410+
// No discovery info provided - treat BaseURL as explicit
411+
skipAutoDiscovery = true
412+
logger.Logger.Info().Msgf("✅ Using explicit callback URL for %s (no discovery mode): %s",
413+
newNode.ID, newNode.BaseURL)
414+
}
415+
}
416+
417+
if len(candidateList) > 0 && !skipAutoDiscovery {
418+
logger.Logger.Debug().Msgf("🔍 Auto-discovering callback URL for %s from %d candidates", newNode.ID, len(candidateList))
398419
probeCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
399420
resolvedBaseURL, normalizedCandidates, probeResults = resolveCallbackCandidates(probeCtx, candidateList, defaultPort)
400421
cancel()
@@ -1165,6 +1186,7 @@ func RegisterServerlessAgentHandler(storageProvider storage.StorageProvider, uiS
11651186
Description string `json:"description"`
11661187
InputSchema map[string]interface{} `json:"input_schema"`
11671188
OutputSchema map[string]interface{} `json:"output_schema"`
1189+
Tags []string `json:"tags"`
11681190
} `json:"reasoners"`
11691191
Skills []struct {
11701192
ID string `json:"id"`
@@ -1204,6 +1226,7 @@ func RegisterServerlessAgentHandler(storageProvider storage.StorageProvider, uiS
12041226
ID: r.ID,
12051227
InputSchema: json.RawMessage(inputSchemaBytes),
12061228
OutputSchema: json.RawMessage(outputSchemaBytes),
1229+
Tags: r.Tags,
12071230
}
12081231
}
12091232

control-plane/internal/handlers/ui/packages.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ type ReasonerDefinition struct {
8181
Description string `json:"description"`
8282
InputSchema map[string]interface{} `json:"input_schema"`
8383
OutputSchema map[string]interface{} `json:"output_schema,omitempty"`
84+
Tags []string `json:"tags,omitempty"`
8485
}
8586

8687
// SkillDefinition represents a skill definition

control-plane/internal/handlers/ui/reasoners.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ type ReasonerWithNode struct {
4141
InputSchema interface{} `json:"input_schema"`
4242
OutputSchema interface{} `json:"output_schema"`
4343
MemoryConfig types.MemoryConfig `json:"memory_config"`
44+
Tags []string `json:"tags"`
4445

4546
// Performance metrics (placeholder for future implementation)
4647
AvgResponseTime *int `json:"avg_response_time_ms,omitempty"`
@@ -141,6 +142,7 @@ func (h *ReasonersHandler) GetAllReasonersHandler(c *gin.Context) {
141142
InputSchema: reasoner.InputSchema,
142143
OutputSchema: reasoner.OutputSchema,
143144
MemoryConfig: reasoner.MemoryConfig,
145+
Tags: reasoner.Tags,
144146
LastUpdated: node.LastHeartbeat,
145147
}
146148

@@ -262,6 +264,7 @@ func (h *ReasonersHandler) GetReasonerDetailsHandler(c *gin.Context) {
262264
InputSchema: foundReasoner.InputSchema,
263265
OutputSchema: foundReasoner.OutputSchema,
264266
MemoryConfig: foundReasoner.MemoryConfig,
267+
Tags: foundReasoner.Tags,
265268
LastUpdated: node.LastHeartbeat,
266269
}
267270

control-plane/pkg/types/types.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,7 @@ type ReasonerDefinition struct {
183183
InputSchema json.RawMessage `json:"input_schema"`
184184
OutputSchema json.RawMessage `json:"output_schema"`
185185
MemoryConfig MemoryConfig `json:"memory_config"`
186+
Tags []string `json:"tags,omitempty"`
186187
}
187188

188189
// SkillDefinition defines a skill provided by an agent node.

control-plane/web/client/src/components/ReasonersList.tsx

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,37 @@ const ReasonersList: React.FC<ReasonersListProps> = ({ reasoners }) => {
2828
</div>
2929
<div className="flex flex-wrap gap-2">
3030
{reasoners.map((reasoner) => (
31-
<Badge key={reasoner.id} variant="secondary" className="text-xs">
32-
{reasoner.id}
33-
</Badge>
31+
<div
32+
key={reasoner.id}
33+
className="min-w-[140px] rounded-lg border border-border-secondary bg-card px-3 py-2"
34+
>
35+
<div className="text-xs font-medium text-text-primary">
36+
{reasoner.id}
37+
</div>
38+
{reasoner.tags && reasoner.tags.length > 0 ? (
39+
<div className="mt-1 flex flex-wrap gap-1">
40+
{reasoner.tags.slice(0, 3).map((tag) => (
41+
<Badge
42+
key={`${reasoner.id}-${tag}`}
43+
variant="outline"
44+
className="text-[10px] bg-background text-text-tertiary border-border-secondary"
45+
>
46+
#{tag}
47+
</Badge>
48+
))}
49+
{reasoner.tags.length > 3 && (
50+
<Badge
51+
variant="outline"
52+
className="text-[10px] bg-background text-text-quaternary border-border-secondary"
53+
>
54+
+{reasoner.tags.length - 3}
55+
</Badge>
56+
)}
57+
</div>
58+
) : (
59+
<p className="mt-1 text-[11px] text-text-tertiary">No tags</p>
60+
)}
61+
</div>
3462
))}
3563
</div>
3664
</div>

control-plane/web/client/src/components/ReasonersSkillsTable.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ export function ReasonersSkillsTable({
8989
exposure_level: reasonerDIDs[reasoner.id]?.exposure_level,
9090
capabilities: reasonerDIDs[reasoner.id]?.capabilities,
9191
memory_retention: reasoner.memory_config?.memory_retention,
92+
tags: reasoner.tags,
9293
})),
9394
...skills.map((skill): TableItem => ({
9495
id: skill.id,

0 commit comments

Comments
 (0)