ask-agent 0.1.0

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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 3a067aefe3faa67280e7ba4cde37994b52f27d5e665e86510e16ee8acf594f80
4
+ data.tar.gz: 4d059bcc2cf98b1fa159c4ab7eba622ba1e529063b7d517343fd3a4f59b97180
5
+ SHA512:
6
+ metadata.gz: 4746d44442fbf8bdf9f67b325914cc57401857151caa2e56d5930d2134e19f03ae18d1064039e414e343539efa4c9c6be26ed2badd59898e3c5deb176f7bebe0
7
+ data.tar.gz: 1c8319c4cb60213a2de06b7cba0fc8443b217918586559af1c0a61e212d4cd30a58eb1d05784d4a5aca0737af65740dd4042285a8b1dc0976d4957cc426b0a5f
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kaka Ruto
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,114 @@
1
+ # ask-agent
2
+
3
+ Agent runtime for the ask-rb ecosystem. The core agent loop: think → call tools → execute → feed back → repeat.
4
+
5
+ Ported from `RubyLLM::Conductor` into the `Ask::Agent` namespace.
6
+
7
+ ## Installation
8
+
9
+ ```ruby
10
+ gem "ask-agent"
11
+ ```
12
+
13
+ ## Quick Start
14
+
15
+ ```ruby
16
+ require "ask-agent"
17
+
18
+ session = Ask::Agent::Session.new(
19
+ model: "gpt-4o",
20
+ tools: [Ask::Tools::Shell::Bash, Ask::Tools::Shell::Read]
21
+ )
22
+
23
+ response = session.run("What files are in the current directory?")
24
+ puts response
25
+ ```
26
+
27
+ ## Components
28
+
29
+ | Component | File | Purpose |
30
+ |---|---|---|
31
+ | `Ask::Agent::Session` | session.rb | Full agent loop — message → tool calls → results → follow-up |
32
+ | `Ask::Agent::Loop` | loop.rb | Turn management, loop detection, max-turn guard |
33
+ | `Ask::Agent::ToolExecutor` | tool_executor.rb | Parallel/sequential tool execution with retry and abort |
34
+ | `Ask::Agent::Compactor` | compactor.rb | Context window management with proactive/overflow compaction |
35
+ | `Ask::Agent::Hooks` | hooks.rb | Before/after tool lifecycle callbacks |
36
+ | `Ask::Agent::Events` | events.rb | Data.define event types for streaming and monitoring |
37
+ | `Ask::Agent::Telemetry` | telemetry.rb | File-backed telemetry for error tracking |
38
+ | `Ask::Agent::Reflector` | reflector.rb | Assistant response self-evaluation |
39
+ | `Ask::Agent::MetaAgent` | meta_agent.rb | LLM-powered self-improvement from telemetry |
40
+ | `Ask::Agent::Configuration` | configuration.rb | Global config: model, turns, concurrency |
41
+
42
+ ## Events
43
+
44
+ Stream session execution in real-time:
45
+
46
+ ```ruby
47
+ session.on_event do |event|
48
+ case event
49
+ when Ask::Agent::Events::TextDelta
50
+ print event.content
51
+ when Ask::Agent::Events::ToolExecutionStart
52
+ puts "\nRunning #{event.name}..."
53
+ when Ask::Agent::Events::ToolExecutionEnd
54
+ puts " → #{event.duration_ms}ms #{event.is_error ? 'error' : 'ok'}"
55
+ end
56
+ end
57
+ ```
58
+
59
+ ## Extensions
60
+
61
+ Opt-in safety modules:
62
+
63
+ - **PermissionGate** — Require approval for destructive tools (write, edit, bash, destroy)
64
+ - **RateLimiter** — Prevent runaway tool calls (configurable per-minute and per-turn limits)
65
+ - **AuditLog** — Immutable, append-only log of every tool call
66
+
67
+ ```ruby
68
+ extensions = [
69
+ Ask::Agent::Extensions::PermissionGate.new,
70
+ Ask::Agent::Extensions::RateLimiter.new(max_calls_per_minute: 30),
71
+ Ask::Agent::Extensions::AuditLog.new(path: "agent.log")
72
+ ]
73
+
74
+ session = Ask::Agent::Session.new(
75
+ model: "gpt-4o",
76
+ tools: [...],
77
+ hooks: {
78
+ before_tool: extensions.map(&:method(:before_tool_call)),
79
+ after_tool: extensions.select { |e| e.respond_to?(:after_tool_call) }.map(&:method(:after_tool_call))
80
+ }
81
+ )
82
+ ```
83
+
84
+ ## Configuration
85
+
86
+ ```ruby
87
+ Ask::Agent.configure do |c|
88
+ c.default_model = "claude-sonnet-4"
89
+ c.default_max_turns = 50
90
+ c.compactor_enabled = true
91
+ c.compactor_threshold = 0.8
92
+ c.parallel_tool_execution = true
93
+ c.max_tool_retries = 3
94
+ end
95
+ ```
96
+
97
+ ## Persistence
98
+
99
+ ```ruby
100
+ store = Ask::Agent::Persistence::InMemory.new
101
+ session = Ask::Agent::Session.new(model: "gpt-4o", persistence: store)
102
+ session.run("Hello")
103
+ session.save # persisted to store
104
+ ```
105
+
106
+ ## Development
107
+
108
+ ```bash
109
+ bundle exec rake test
110
+ ```
111
+
112
+ ## License
113
+
114
+ MIT
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Agent
5
+ class Compactor
6
+ CONTEXT_WINDOWS = {
7
+ "gpt-4o" => 128_000,
8
+ "gpt-4o-mini" => 128_000,
9
+ "gpt-4-turbo" => 128_000,
10
+ "claude-sonnet-4" => 200_000,
11
+ "claude-4" => 200_000,
12
+ "gemini-2.0-flash" => 1_048_576,
13
+ "gemini-2.5-pro" => 1_048_576,
14
+ "deepseek-v4-flash" => 1_000_000,
15
+ "deepseek-v4-pro" => 1_000_000,
16
+ }.tap { |h| h.default = 128_000 }
17
+
18
+ attr_accessor :chat, :llm
19
+
20
+ def initialize(threshold: 0.8, strategy: :proactive, llm: nil)
21
+ @threshold = threshold
22
+ @strategy = strategy
23
+ @llm = llm
24
+ @already_compacted = false
25
+ @overflow_recovered = false
26
+ end
27
+
28
+ def overflow_recovered? = @overflow_recovered
29
+
30
+ def should_compact?
31
+ return false unless @chat
32
+ current = estimate_total_tokens
33
+ window = context_window
34
+ current >= window * @threshold
35
+ end
36
+
37
+ def run(event_emitter: nil)
38
+ return unless @chat
39
+
40
+ tokens_before = estimate_total_tokens
41
+ event_emitter&.emit(Events::CompactionStart.new(tokens_before: tokens_before, reason: :threshold))
42
+ compact!
43
+ tokens_after = estimate_total_tokens
44
+ @already_compacted = true
45
+ event_emitter&.emit(Events::CompactionEnd.new(tokens_before: tokens_before, tokens_after: tokens_after, summary: extract_summary))
46
+ end
47
+
48
+ def compact!
49
+ return unless @chat
50
+ messages = @chat.messages.dup
51
+ return if messages.size < 6
52
+
53
+ keep_count = [messages.size, 8].min
54
+ recent = messages.last(keep_count)
55
+ older = messages.first(messages.size - keep_count)
56
+ return if older.empty?
57
+
58
+ summary = if @llm
59
+ generate_llm_summary(older) || generate_summary(older)
60
+ else
61
+ generate_summary(older)
62
+ end
63
+
64
+ older.size.times { @chat.messages.delete_at(0) }
65
+ @chat.add_message(role: :system, content: "[Previous conversation summary]: #{summary}")
66
+ end
67
+
68
+ def microcompact!
69
+ return unless @chat
70
+ @chat.messages.each do |msg|
71
+ next unless msg.role == :tool
72
+ msg.content = "[Tool result cleared by compaction]" if msg.content.to_s.length > 200
73
+ end
74
+ end
75
+
76
+ def recover_from_overflow
77
+ if @already_compacted then microcompact! else compact! end
78
+ @already_compacted = true
79
+ @overflow_recovered = true
80
+ end
81
+
82
+ def estimate_tokens(text)
83
+ (text.to_s.length / 4.0).ceil
84
+ end
85
+
86
+ def estimate_total_tokens
87
+ return 0 unless @chat
88
+ @chat.messages.sum { |msg| estimate_message_tokens(msg) }
89
+ end
90
+
91
+ def context_window
92
+ model = @chat.model.to_s
93
+ CONTEXT_WINDOWS[model]
94
+ end
95
+
96
+ private
97
+
98
+ def estimate_message_tokens(message)
99
+ base = estimate_tokens(message.content.to_s)
100
+ if message.tool_call? && message.respond_to?(:tool_calls) && message.tool_calls
101
+ base + message.tool_calls.sum { |_, tc| estimate_tokens(tc.name.to_s) + estimate_tokens(tc.arguments.to_s) }
102
+ else
103
+ base
104
+ end
105
+ end
106
+
107
+ def generate_summary(messages)
108
+ lines = messages.each_cons(2)
109
+ .select { |a, _b| a.role == :user }
110
+ .map { |u, a| "- Asked: #{u.content.to_s[0, 80]} → #{a.content.to_s[0, 120]}" }
111
+ lines.empty? ? "Previous conversation context." : lines.join("\n")
112
+ end
113
+
114
+ def generate_llm_summary(messages)
115
+ prompt = "Summarize this conversation concisely. Focus on goals accomplished, key info, decisions, and pending actions.\n\n#{serialize_conversation(messages)}"
116
+ response = build_llm_chat.ask(prompt)
117
+ text = response.content.to_s.strip
118
+ text.empty? ? nil : text
119
+ rescue
120
+ nil
121
+ end
122
+
123
+ def serialize_conversation(messages)
124
+ messages.map { |m|
125
+ role = m.role == :user ? "Human" : "Assistant"
126
+ content = if m.tool_call? && m.respond_to?(:tool_calls) && m.tool_calls
127
+ details = m.tool_calls.map { |_, tc| " - Called #{tc.name} with #{tc.arguments}" }.join("\n")
128
+ "#{m.content}\n#{details}"
129
+ elsif m.role == :tool
130
+ c = m.content.to_s[0, 500]
131
+ "[Tool result]: #{c}"
132
+ else
133
+ m.content.to_s
134
+ end
135
+ "#{role}: #{content}"
136
+ }.join("\n---\n")
137
+ end
138
+
139
+ def build_llm_chat
140
+ if @llm.respond_to?(:ask) then @llm
141
+ elsif @llm.is_a?(String) then RubyLLM::Chat.new(model: @llm)
142
+ else RubyLLM::Chat.new end
143
+ end
144
+
145
+ def extract_summary
146
+ @chat.messages.each { |msg| return msg.content.to_s if msg.content.to_s.start_with?("[Previous conversation summary]") }
147
+ ""
148
+ end
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Agent
5
+ class Configuration
6
+ attr_accessor :default_model, :default_max_turns, :compactor_enabled,
7
+ :compactor_threshold, :parallel_tool_execution, :max_tool_retries
8
+
9
+ def initialize
10
+ @default_model = "gpt-4o"
11
+ @default_max_turns = 25
12
+ @compactor_enabled = true
13
+ @compactor_threshold = 0.8
14
+ @parallel_tool_execution = true
15
+ @max_tool_retries = 3
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Agent
5
+ module Events
6
+ SessionStart = Data.define
7
+ SessionEnd = Data.define(:result, :turn_count, :tool_calls_made)
8
+
9
+ TurnStart = Data.define
10
+ TurnEnd = Data.define(:tool_results, :turn_number)
11
+
12
+ MessageStart = Data.define
13
+ TextDelta = Data.define(:content)
14
+ ToolCallDelta = Data.define(:name, :arguments, :id)
15
+ MessageEnd = Data.define(:tool_calls)
16
+
17
+ ToolExecutionStart = Data.define(:name, :arguments, :id)
18
+ ToolExecutionUpdate = Data.define(:name, :id, :partial_result)
19
+ ToolExecutionEnd = Data.define(:name, :id, :result, :is_error, :duration_ms)
20
+
21
+ CompactionStart = Data.define(:tokens_before, :reason)
22
+ CompactionEnd = Data.define(:tokens_before, :tokens_after, :summary)
23
+
24
+ LoopDetected = Data.define(:tool_name, :repeated_count)
25
+ MaxTurnsExceeded = Data.define(:max_turns)
26
+
27
+ ReflectionStart = Data.define(:reflection_number)
28
+ ReflectionDelta = Data.define(:content)
29
+ ReflectionEnd = Data.define(:decision, :feedback)
30
+
31
+ MetaAgentAnalysis = Data.define(:results, :count)
32
+
33
+ Error = Data.define(:error, :recoverable)
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Agent
5
+ module Extensions
6
+ class AuditLog
7
+ def initialize(output: $stdout, path: nil)
8
+ @entries = []
9
+ @mutex = Mutex.new
10
+
11
+ if path
12
+ @io = File.open(path, "a")
13
+ @io.sync = true
14
+ else
15
+ @io = output
16
+ end
17
+ end
18
+
19
+ def after_tool_call(tool_call, result, _context)
20
+ entry = {
21
+ timestamp: Time.now.utc.iso8601(3),
22
+ tool_name: tool_call.name,
23
+ arguments: tool_call.arguments,
24
+ result: result,
25
+ duration: result[:duration_ms]
26
+ }
27
+
28
+ @mutex.synchronize do
29
+ @entries << entry
30
+ @io.puts entry.to_json
31
+ end
32
+
33
+ nil
34
+ end
35
+
36
+ def entries
37
+ @mutex.synchronize { @entries.dup }
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Agent
5
+ module Extensions
6
+ class PermissionGate
7
+ DEFAULT_TOOLS = %i[write edit bash destroy].freeze
8
+
9
+ def initialize(blocked_tools: DEFAULT_TOOLS, timeout: nil)
10
+ @blocked_tools = Array(blocked_tools).map(&:to_sym)
11
+ @timeout = timeout
12
+ @pending = {}
13
+ @mutex = Mutex.new
14
+ end
15
+
16
+ def before_tool_call(tool_call, _context)
17
+ return { action: :proceed } unless @blocked_tools.include?(tool_call.name.to_sym)
18
+
19
+ if approved?(tool_call)
20
+ { action: :proceed }
21
+ else
22
+ request_approval(tool_call)
23
+ end
24
+ end
25
+
26
+ def approve(tool_call_id)
27
+ @mutex.synchronize do
28
+ entry = @pending[tool_call_id]
29
+ return false unless entry
30
+ entry[:approved] = true
31
+ end
32
+ end
33
+
34
+ def pending_approvals
35
+ @mutex.synchronize { @pending.values.reject { |e| e[:approved] } }
36
+ end
37
+
38
+ private
39
+
40
+ def approved?(tool_call)
41
+ @mutex.synchronize do
42
+ key = tool_call.id
43
+ entry = @pending[key]
44
+ return false unless entry
45
+
46
+ if @timeout && (Time.now - entry[:created_at]) > @timeout
47
+ @pending.delete(key)
48
+ return false
49
+ end
50
+
51
+ entry[:approved]
52
+ end
53
+ end
54
+
55
+ def request_approval(tool_call)
56
+ @mutex.synchronize do
57
+ @pending[tool_call.id] = {
58
+ tool_call: tool_call,
59
+ approved: false,
60
+ created_at: Time.now
61
+ }
62
+ end
63
+
64
+ warn "[PermissionGate] Tool '#{tool_call.name}' requires approval. Call approve('#{tool_call.id}') to allow."
65
+ { action: :block, reason: "Tool '#{tool_call.name}' requires approval" }
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Agent
5
+ module Extensions
6
+ class RateLimiter
7
+ def initialize(max_calls_per_minute: 20, max_tool_calls_per_turn: 5)
8
+ @max_calls_per_minute = max_calls_per_minute
9
+ @max_tool_calls_per_turn = max_tool_calls_per_turn
10
+ @turn_calls = 0
11
+ @minute_window = []
12
+ @mutex = Mutex.new
13
+ end
14
+
15
+ def before_tool_call(tool_call, _context)
16
+ now = Time.now
17
+
18
+ @mutex.synchronize do
19
+ @turn_calls += 1
20
+
21
+ if @turn_calls > @max_tool_calls_per_turn
22
+ return { action: :block, reason: "Exceeded #{@max_tool_calls_per_turn} tool calls per turn" }
23
+ end
24
+
25
+ @minute_window << now
26
+ @minute_window.reject! { |t| now - t > 60 }
27
+
28
+ if @minute_window.size > @max_calls_per_minute
29
+ return { action: :block, reason: "Exceeded #{@max_calls_per_minute} tool calls per minute" }
30
+ end
31
+ end
32
+
33
+ { action: :proceed }
34
+ end
35
+
36
+ def reset_turn!
37
+ @mutex.synchronize { @turn_calls = 0 }
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Agent
5
+ class Hooks
6
+ def initialize(hooks = {})
7
+ @before_tool = Array(hooks[:before_tool])
8
+ @after_tool = Array(hooks[:after_tool])
9
+ end
10
+
11
+ def run_before_tool(tool_call, context)
12
+ result = nil
13
+ @before_tool.each do |hook|
14
+ result = hook.call(tool_call, context)
15
+ break if result.is_a?(Hash) && result[:action] != :proceed
16
+ end
17
+ result
18
+ end
19
+
20
+ def run_after_tool(tool_call, result, context)
21
+ final = nil
22
+ @after_tool.each do |hook|
23
+ final = hook.call(tool_call, result, context)
24
+ break if final.is_a?(Hash) && final[:action] == :block
25
+ end
26
+ final
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Agent
5
+ class Loop
6
+ LOOP_DETECTION_WINDOW = 3
7
+ MAX_CONSECUTIVE_TOOL_TURNS = 6
8
+
9
+ attr_reader :turn_count
10
+
11
+ def initialize(max_turns: 25)
12
+ @max_turns = max_turns
13
+ @turn_count = 0
14
+ @recent_results = []
15
+ @loop_detected = false
16
+ @consecutive_tool_turns = 0
17
+ end
18
+
19
+ def run_turn(chat:, message:, tools:, tool_executor:, compactor:, hooks:, event_emitter:, session_id: nil)
20
+ raise MaxTurnsExceeded if @turn_count >= @max_turns
21
+
22
+ event_emitter.emit(Events::TurnStart.new)
23
+
24
+ response = chat.ask(message) do |chunk|
25
+ if chunk.content.to_s.strip.length > 0
26
+ event_emitter.emit(Events::TextDelta.new(content: chunk.content))
27
+ end
28
+
29
+ if chunk.tool_call?
30
+ chunk.tool_calls.each do |id, tc|
31
+ event_emitter.emit(Events::ToolCallDelta.new(
32
+ name: tc.name, arguments: tc.arguments, id: tc.id
33
+ ))
34
+ end
35
+ end
36
+ end
37
+
38
+ event_emitter.emit(Events::MessageEnd.new(tool_calls: response.tool_call?))
39
+ @turn_count += 1
40
+
41
+ unless response.tool_call?
42
+ @consecutive_tool_turns = 0
43
+ return response.content.to_s
44
+ end
45
+
46
+ @consecutive_tool_turns += 1
47
+
48
+ # Execute tools with concurrent result streaming
49
+ tool_results = tool_executor.execute_parallel(
50
+ response.tool_calls, tools, hooks, event_emitter, ToolAbortController.new
51
+ ) do |tool_call_id, result|
52
+ # Add each tool result as it completes (concurrent streaming)
53
+ tc = response.tool_calls[tool_call_id]
54
+ chat.add_message(role: :tool, content: result[:message].to_s, tool_call_id: tool_call_id) if tc
55
+ end
56
+
57
+ # Check loop detection
58
+ if loop_detected?(tool_results)
59
+ raise LoopDetected, tool_results.last[:tool_name]
60
+ end
61
+
62
+ if @consecutive_tool_turns >= MAX_CONSECUTIVE_TOOL_TURNS
63
+ summary = tool_results.map { |r| r[:message].to_s.truncate(80) }.first(2).join("; ")
64
+ return "Based on my investigation: #{summary}"
65
+ end
66
+
67
+ event_emitter.emit(Events::TurnEnd.new(tool_results: tool_results, turn_number: @turn_count))
68
+
69
+ if compactor && compactor.should_compact?
70
+ compactor.run(event_emitter: event_emitter)
71
+ end
72
+
73
+ raise MaxTurnsExceeded if @turn_count >= @max_turns
74
+
75
+ # Recursive call — LLM processes tool results
76
+ run_turn(
77
+ chat: chat,
78
+ message: "",
79
+ tools: tools,
80
+ tool_executor: tool_executor,
81
+ compactor: compactor,
82
+ hooks: hooks,
83
+ event_emitter: event_emitter,
84
+ session_id: session_id
85
+ )
86
+ end
87
+
88
+ def reset!
89
+ @turn_count = 0
90
+ @recent_results = []
91
+ @loop_detected = false
92
+ end
93
+
94
+ private
95
+
96
+ def loop_detected?(results)
97
+ return false if results.empty?
98
+
99
+ results.each do |result|
100
+ signature = [result[:tool_name], result[:message].to_s.strip]
101
+ @recent_results << signature
102
+ @recent_results.shift if @recent_results.size > LOOP_DETECTION_WINDOW
103
+
104
+ recent = @recent_results.last(LOOP_DETECTION_WINDOW)
105
+ if recent.size >= LOOP_DETECTION_WINDOW && recent.uniq.size == 1
106
+ @loop_detected = true
107
+ return true
108
+ end
109
+ end
110
+ false
111
+ end
112
+ end
113
+ end
114
+ end