11// swiftlint:disable file_length
22import Foundation
33
4+ public enum Limits {
5+ public static let maxOutputSize = 50_000
6+ public static let defaultMaxTokens = 4096
7+ static let logPreviewLength = 200
8+ }
9+
410public final class Agent {
5- public static let version = " 0.3 .0 "
11+ public static let version = " 0.4 .0 "
612
713 private static let todoReminderThreshold = 3
814
@@ -33,60 +39,69 @@ public final class Agent {
3339
3440 public func run( query: String ) async throws -> String {
3541 messages. append ( . user( query) )
42+
43+ let result = try await agentLoop ( initialMessages: messages, config: . default)
44+ messages = result. messages
45+
46+ return result. text
47+ }
48+
49+ private func agentLoop(
50+ initialMessages: [ Message ] ,
51+ config: LoopConfig
52+ ) async throws -> ( text: String , messages: [ Message ] ) {
53+ var messages = initialMessages
3654 var turnsWithoutTodo = 0
55+ var iteration = 0
56+ var lastAssistantText = " "
57+
58+ let allowedTools = Set ( config. tools. map ( \. name) )
3759
3860 while true {
61+ try Task . checkCancellation ( )
62+
63+ iteration += 1
64+ if iteration > config. maxIterations {
65+ return ( lastAssistantText + " \n ( \( config. label) reached iteration limit) " , messages)
66+ }
67+
3968 let request = APIRequest (
4069 model: model,
41- maxTokens: 4096 ,
70+ maxTokens: Limits . defaultMaxTokens ,
4271 system: systemPrompt,
4372 messages: messages,
44- tools: Self . toolDefinitions
73+ tools: config . tools
4574 )
4675
4776 let response = try await apiClient. createMessage ( request: request)
4877 messages. append ( Message ( role: . assistant, content: response. content) )
78+ lastAssistantText = response. content. textContent
4979
5080 for block in response. content {
5181 if case . text( let text) = block {
52- print ( " \( ANSIColor . cyan) \( text) \( ANSIColor . reset) " )
82+ print ( " [ \( config . label ) ] \( ANSIColor . cyan) \( text) \( ANSIColor . reset) " )
5383 }
5484 }
5585
5686 guard response. stopReason == . toolUse else {
57- return response. content. textContent
87+ return ( response. content. textContent, messages )
5888 }
5989
60- var results : [ ContentBlock ] = [ ]
61- var didUseTodo = false
90+ let ( results, didUseTodo) = await processToolUses (
91+ response: response,
92+ allowedTools: allowedTools,
93+ label: config. label
94+ )
6295
63- for block in response. content {
64- if case . toolUse( let id, let name, let input) = block {
65- printToolCall ( name: name, input: input)
66- let toolResult = await executeTool ( name: name, input: input)
67-
68- if name == " todo " {
69- didUseTodo = true
70- }
71-
72- switch toolResult {
73- case . success( let output) :
74- print ( " \( ANSIColor . dim) \( String ( output. prefix ( 200 ) ) ) \( ANSIColor . reset) " )
75- results. append ( . toolResult( toolUseId: id, content: output, isError: false ) )
76- case . failure( let error) :
77- let message = " \( error) "
78- print ( " \( ANSIColor . red) \( message) \( ANSIColor . reset) " )
79- results. append ( . toolResult( toolUseId: id, content: message, isError: true ) )
80- }
96+ var toolResults = results
97+ if config. enableNag {
98+ turnsWithoutTodo = didUseTodo ? 0 : turnsWithoutTodo + 1
99+ if turnsWithoutTodo >= Self . todoReminderThreshold && todoManager. hasOpenItems ( ) {
100+ toolResults. append ( . text( " Update your todos. " ) )
81101 }
82102 }
83103
84- turnsWithoutTodo = didUseTodo ? 0 : turnsWithoutTodo + 1
85- if turnsWithoutTodo >= Self . todoReminderThreshold && todoManager. hasOpenItems ( ) {
86- results. append ( . text( " Update your todos. " ) )
87- }
88-
89- messages. append ( Message ( role: . user, content: results) )
104+ messages. append ( Message ( role: . user, content: toolResults) )
90105 }
91106 }
92107
@@ -208,6 +223,20 @@ extension Agent {
208223 ] ) ,
209224 " required " : . array( [ " items " ] )
210225 ] )
226+ ) ,
227+ ToolDefinition (
228+ name: " agent " ,
229+ description: " Spawn a subagent to handle a complex subtask independently. " ,
230+ inputSchema: . object( [
231+ " type " : " object " ,
232+ " properties " : . object( [
233+ " prompt " : . object( [
234+ " type " : " string " ,
235+ " description " : " The task for the subagent to complete "
236+ ] )
237+ ] ) ,
238+ " required " : . array( [ " prompt " ] )
239+ ] )
211240 )
212241 ]
213242
@@ -217,7 +246,8 @@ extension Agent {
217246 " read_file " : executeReadFile,
218247 " write_file " : executeWriteFile,
219248 " edit_file " : executeEditFile,
220- " todo " : executeTodo
249+ " todo " : executeTodo,
250+ " agent " : executeAgent
221251 ]
222252
223253 guard let handler = handlers [ name] else {
@@ -265,8 +295,8 @@ extension Agent {
265295 output = text
266296 }
267297
268- if output. count > 50_000 {
269- output = String ( output. prefix ( 50_000 ) )
298+ if output. count > Limits . maxOutputSize {
299+ output = String ( output. prefix ( Limits . maxOutputSize ) )
270300 }
271301
272302 return . success( output)
@@ -339,6 +369,30 @@ extension Agent {
339369 }
340370 }
341371
372+ private func executeAgent( _ input: JSONValue ) async -> Result < String , ToolError > {
373+ guard let prompt = input [ " prompt " ] ? . stringValue else {
374+ return . failure( . missingParameter( " prompt " ) )
375+ }
376+
377+ do {
378+ let result = try await agentLoop (
379+ initialMessages: [ Message . user ( prompt) ] ,
380+ config: . subagent
381+ )
382+ var output = result. text
383+
384+ if output. isEmpty {
385+ output = " (no output) "
386+ } else if output. count > Limits . maxOutputSize {
387+ output = String ( output. prefix ( Limits . maxOutputSize) )
388+ }
389+
390+ return . success( output)
391+ } catch {
392+ return . failure( . executionFailed( " Subagent failed: \( error) " ) )
393+ }
394+ }
395+
342396 private func executeTodo( _ input: JSONValue ) async -> Result < String , ToolError > {
343397 guard let itemsArray = input [ " items " ] ? . arrayValue else {
344398 return . failure( . missingParameter( " items " ) )
@@ -371,6 +425,43 @@ extension Agent {
371425
372426 // MARK: Helpers
373427
428+ private func processToolUses(
429+ response: APIResponse ,
430+ allowedTools: Set < String > ,
431+ label: String
432+ ) async -> ( results: [ ContentBlock ] , didUseTodo: Bool ) {
433+ var results : [ ContentBlock ] = [ ]
434+ var didUseTodo = false
435+
436+ for case . toolUse( let id, let name, let input) in response. content {
437+ guard allowedTools. contains ( name) else {
438+ let message = " Tool ' \( name) ' is not allowed in this context "
439+ print ( " [ \( label) ] \( ANSIColor . red) \( message) \( ANSIColor . reset) " )
440+ results. append ( . toolResult( toolUseId: id, content: message, isError: true ) )
441+ continue
442+ }
443+
444+ printToolCall ( name: name, input: input, label: label)
445+ let toolResult = await executeTool ( name: name, input: input)
446+
447+ if name == " todo " {
448+ didUseTodo = true
449+ }
450+
451+ switch toolResult {
452+ case . success( let output) :
453+ print ( " [ \( label) ] \( ANSIColor . dim) \( String ( output. prefix ( Limits . logPreviewLength) ) ) \( ANSIColor . reset) " )
454+ results. append ( . toolResult( toolUseId: id, content: output, isError: false ) )
455+ case . failure( let error) :
456+ let message = " \( error) "
457+ print ( " [ \( label) ] \( ANSIColor . red) \( message) \( ANSIColor . reset) " )
458+ results. append ( . toolResult( toolUseId: id, content: message, isError: true ) )
459+ }
460+ }
461+
462+ return ( results, didUseTodo)
463+ }
464+
374465 private func resolveSafePath( _ relativePath: String ) -> Result < String , ToolError > {
375466 let workDirURL = URL ( fileURLWithPath: workingDirectory, isDirectory: true )
376467 let resolvedWorkDir = workDirURL. standardized
@@ -391,13 +482,38 @@ extension Agent {
391482 return . success( fullURL. path)
392483 }
393484
394- private func printToolCall( name: String , input: JSONValue ) {
485+ private func printToolCall( name: String , input: JSONValue , label : String ) {
395486 if name == " bash " , let command = input [ " command " ] ? . stringValue {
396- print ( " \( ANSIColor . yellow) $ \( command) \( ANSIColor . reset) " )
487+ print ( " [ \( label ) ] \( ANSIColor . yellow) $ \( command) \( ANSIColor . reset) " )
397488 } else if let path = input [ " path " ] ? . stringValue {
398- print ( " \( ANSIColor . yellow) > \( name) : \( path) \( ANSIColor . reset) " )
489+ print ( " [ \( label ) ] \( ANSIColor . yellow) > \( name) : \( path) \( ANSIColor . reset) " )
399490 } else {
400- print ( " \( ANSIColor . yellow) > \( name) \( ANSIColor . reset) " )
491+ print ( " [ \( label ) ] \( ANSIColor . yellow) > \( name) \( ANSIColor . reset) " )
401492 }
402493 }
403494}
495+
496+ // MARK: - Configuration
497+
498+ extension Agent {
499+ fileprivate struct LoopConfig {
500+ let tools : [ ToolDefinition ]
501+ let maxIterations : Int
502+ let enableNag : Bool
503+ let label : String
504+
505+ static let `default` = LoopConfig (
506+ tools: Agent . toolDefinitions,
507+ maxIterations: . max,
508+ enableNag: true ,
509+ label: " agent "
510+ )
511+
512+ static let subagent = LoopConfig (
513+ tools: Agent . toolDefinitions. filter { $0. name != " agent " && $0. name != " todo " } ,
514+ maxIterations: 30 ,
515+ enableNag: false ,
516+ label: " subagent "
517+ )
518+ }
519+ }
0 commit comments