| using System; |
| using System.Collections.Generic; |
| using System.Globalization; |
| using System.Text; |
| using System.Text.RegularExpressions; |
| using UnityEngine; |
| namespace OnDeviceAgent.AgentCore |
| { |
|
|
| public enum AgentEventPriority |
| { |
| Normal, |
| High |
| } |
|
|
| public sealed class AgentEvent |
| { |
| public string Source; |
| public string Prompt; |
| public AgentEventPriority Priority; |
| public DateTime TimestampUtc; |
| } |
|
|
| public sealed class AgentEventBus |
| { |
| readonly object m_Lock = new object(); |
| readonly Queue<AgentEvent> m_NormalEvents = new Queue<AgentEvent>(); |
| readonly Queue<AgentEvent> m_HighPriorityEvents = new Queue<AgentEvent>(); |
|
|
| public int Count |
| { |
| get |
| { |
| lock (m_Lock) |
| return m_NormalEvents.Count + m_HighPriorityEvents.Count; |
| } |
| } |
|
|
| public void Publish(string source, string prompt, AgentEventPriority priority) |
| { |
| var agentEvent = new AgentEvent |
| { |
| Source = string.IsNullOrWhiteSpace(source) ? "Event" : source.Trim(), |
| Prompt = prompt ?? string.Empty, |
| Priority = priority, |
| TimestampUtc = DateTime.UtcNow |
| }; |
|
|
| lock (m_Lock) |
| { |
| if (priority == AgentEventPriority.High) |
| m_HighPriorityEvents.Enqueue(agentEvent); |
| else |
| m_NormalEvents.Enqueue(agentEvent); |
| } |
| } |
|
|
| public bool TryDequeue(out AgentEvent agentEvent) |
| { |
| lock (m_Lock) |
| { |
| if (m_HighPriorityEvents.Count > 0) |
| { |
| agentEvent = m_HighPriorityEvents.Dequeue(); |
| return true; |
| } |
|
|
| if (m_NormalEvents.Count > 0) |
| { |
| agentEvent = m_NormalEvents.Dequeue(); |
| return true; |
| } |
|
|
| agentEvent = null; |
| return false; |
| } |
| } |
|
|
| public void Clear() |
| { |
| lock (m_Lock) |
| { |
| m_NormalEvents.Clear(); |
| m_HighPriorityEvents.Clear(); |
| } |
| } |
| } |
|
|
| public sealed class AgentInterruptRequest |
| { |
| public bool ShouldCancel; |
| public string FollowUpPrompt; |
| public string Reason; |
| } |
|
|
| public sealed class AgentInterruptController |
| { |
| static readonly string[] s_CancelPhrases = |
| { |
| "멈춰", |
| "중지", |
| "취소", |
| "그만", |
| "stop", |
| "cancel", |
| "interrupt", |
| "quit", |
| "halt" |
| }; |
|
|
| static readonly string[] s_RedirectPhrases = |
| { |
| "아니", |
| "대신", |
| "다시", |
| "그거 말고", |
| "instead", |
| "actually" |
| }; |
|
|
| public bool TryCreateInterrupt(string text, out AgentInterruptRequest request) |
| { |
| request = null; |
| var normalized = Normalize(text); |
| if (string.IsNullOrWhiteSpace(normalized)) |
| return false; |
|
|
| if (ContainsAny(normalized, s_CancelPhrases)) |
| { |
| request = new AgentInterruptRequest |
| { |
| ShouldCancel = true, |
| Reason = "cancel phrase", |
| FollowUpPrompt = ExtractFollowUpPrompt(text) |
| }; |
| return true; |
| } |
|
|
| if (ContainsAny(normalized, s_RedirectPhrases)) |
| { |
| request = new AgentInterruptRequest |
| { |
| ShouldCancel = true, |
| Reason = "redirect phrase", |
| FollowUpPrompt = text.Trim() |
| }; |
| return true; |
| } |
|
|
| return false; |
| } |
|
|
| static string ExtractFollowUpPrompt(string text) |
| { |
| var trimmed = string.IsNullOrWhiteSpace(text) ? string.Empty : text.Trim(); |
| var match = Regex.Match( |
| trimmed, |
| "(?:멈춰|중지|취소|그만|stop|cancel|interrupt|quit|halt)[,\\s]*(?<follow>.+)$", |
| RegexOptions.IgnoreCase); |
| return match.Success ? match.Groups["follow"].Value.Trim() : string.Empty; |
| } |
|
|
| static string Normalize(string text) |
| { |
| return string.IsNullOrWhiteSpace(text) ? string.Empty : Regex.Replace(text.Trim().ToLowerInvariant(), "\\s+", " "); |
| } |
|
|
| static bool ContainsAny(string text, string[] phrases) |
| { |
| for (var i = 0; i < phrases.Length; i++) |
| { |
| if (MatchesPhrase(text, phrases[i])) |
| return true; |
| } |
|
|
| return false; |
| } |
|
|
| static bool MatchesPhrase(string text, string phrase) |
| { |
| if (string.IsNullOrEmpty(phrase)) |
| return false; |
|
|
| var lowered = phrase.ToLowerInvariant(); |
| if (IsAsciiPhrase(lowered)) |
| { |
| var pattern = "\\b" + Regex.Escape(lowered) + "\\b"; |
| return Regex.IsMatch(text, pattern, RegexOptions.IgnoreCase); |
| } |
|
|
| return text.Contains(lowered); |
| } |
|
|
| static bool IsAsciiPhrase(string phrase) |
| { |
| for (var i = 0; i < phrase.Length; i++) |
| { |
| if (phrase[i] > 127) |
| return false; |
| } |
|
|
| return true; |
| } |
| } |
|
|
| public sealed class AgentTraceStore |
| { |
| readonly List<string> m_Events = new List<string>(); |
| int m_MaxEvents = 80; |
|
|
| public void Configure(int maxEvents) |
| { |
| m_MaxEvents = Mathf.Max(10, maxEvents); |
| TrimEvents(); |
| } |
|
|
| public void RecordEvent(string kind, string detail) |
| { |
| var line = DateTime.Now.ToString("HH:mm:ss", CultureInfo.InvariantCulture) + |
| " [" + (string.IsNullOrWhiteSpace(kind) ? "event" : kind.Trim()) + "] " + |
| AgentHarnessText.OneLine(detail, 320); |
| m_Events.Add(line); |
| TrimEvents(); |
| } |
|
|
| public void RecordToolCall(string toolName, string detail) |
| { |
| RecordEvent("tool-call", toolName + " " + AgentHarnessText.OneLine(detail, 220)); |
| } |
|
|
| public void RecordToolResult(string toolName, string result) |
| { |
| RecordEvent("tool-result", toolName + " -> " + AgentHarnessText.OneLine(result, 260)); |
| } |
|
|
| public string BuildSummary() |
| { |
| if (m_Events.Count == 0) |
| return string.Empty; |
|
|
| var summary = new StringBuilder(); |
| summary.AppendLine("Trace summary:"); |
| for (var i = 0; i < m_Events.Count; i++) |
| summary.Append("- ").AppendLine(m_Events[i]); |
|
|
| return summary.ToString().Trim(); |
| } |
|
|
| public void Clear() |
| { |
| m_Events.Clear(); |
| } |
|
|
| void TrimEvents() |
| { |
| while (m_Events.Count > m_MaxEvents) |
| m_Events.RemoveAt(0); |
| } |
| } |
|
|
| public sealed class AgentToolGuardrailController |
| { |
| readonly object m_Lock = new object(); |
| readonly Dictionary<string, int> m_CallCounts = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase); |
| readonly Dictionary<string, int> m_ResultCounts = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase); |
| int m_RepeatedCallLimit = 3; |
| int m_RepeatedResultWarnAfter = 2; |
|
|
| public void Configure(int repeatedCallLimit, int repeatedResultWarnAfter) |
| { |
| lock (m_Lock) |
| { |
| m_RepeatedCallLimit = Mathf.Max(1, repeatedCallLimit); |
| m_RepeatedResultWarnAfter = Mathf.Max(1, repeatedResultWarnAfter); |
| } |
| } |
|
|
| public void ResetForRun() |
| { |
| lock (m_Lock) |
| { |
| m_CallCounts.Clear(); |
| m_ResultCounts.Clear(); |
| } |
| } |
|
|
| public bool TryBeginToolCall(string toolName, string arguments, out string blockedMessage) |
| { |
| var key = BuildKey(toolName, arguments); |
| int count; |
| int limit; |
| lock (m_Lock) |
| { |
| m_CallCounts.TryGetValue(key, out count); |
| count++; |
| m_CallCounts[key] = count; |
| limit = m_RepeatedCallLimit; |
| } |
|
|
| if (count <= limit) |
| { |
| blockedMessage = string.Empty; |
| return true; |
| } |
|
|
| blockedMessage = "Tool guardrail blocked repeated call to " + toolName + |
| ". The same arguments were attempted " + count.ToString(CultureInfo.InvariantCulture) + " times in this run."; |
| return false; |
| } |
|
|
| public string InspectToolResult(string toolName, string result, out bool shouldWarn) |
| { |
| shouldWarn = false; |
| var normalized = AgentHarnessText.OneLine(result, 500); |
| var key = toolName + ":" + HashText(normalized); |
|
|
| int count; |
| int warnAfter; |
| lock (m_Lock) |
| { |
| m_ResultCounts.TryGetValue(key, out count); |
| count++; |
| m_ResultCounts[key] = count; |
| warnAfter = m_RepeatedResultWarnAfter; |
| } |
|
|
| if (count <= warnAfter) |
| return result; |
|
|
| shouldWarn = true; |
| return result + "\n\nGuardrail note: this tool returned the same result repeatedly in the current run. Avoid retrying unless the user asks."; |
| } |
|
|
| static string BuildKey(string toolName, string arguments) |
| { |
| return (toolName ?? string.Empty).Trim() + ":" + HashText(arguments ?? string.Empty); |
| } |
|
|
| static string HashText(string text) |
| { |
| |
| const ulong offset = 14695981039346656037UL; |
| const ulong prime = 1099511628211UL; |
| var hash = offset; |
| var s = text ?? string.Empty; |
| for (var i = 0; i < s.Length; i++) |
| { |
| hash ^= s[i]; |
| hash *= prime; |
| } |
| return hash.ToString("x16"); |
| } |
| } |
|
|
| public sealed class AgentMemoryCompactionPolicy |
| { |
| public int RecentTurnCount = 6; |
| public int CompressionThreshold = 12; |
| public int MaxContextChars = 8000; |
|
|
| public string BuildContext(string summary, List<ConversationTurn> turns) |
| { |
| var maxContextChars = Mathf.Max(1000, MaxContextChars); |
| var memory = new StringBuilder(); |
|
|
| if (!string.IsNullOrWhiteSpace(summary)) |
| { |
| memory.AppendLine("Summary:"); |
| memory.AppendLine(AgentHarnessText.TrimToMaxChars(summary.Trim(), maxContextChars / 2)); |
| } |
|
|
| if (turns != null && turns.Count > 0) |
| { |
| if (memory.Length > 0) memory.AppendLine(); |
| memory.AppendLine("Recent turns:"); |
| var recentCount = Mathf.Max(1, RecentTurnCount); |
| var start = Math.Max(0, turns.Count - recentCount); |
| for (var i = start; i < turns.Count; i++) |
| AppendMemoryTurn(memory, turns[i]); |
| } |
|
|
| return AgentHarnessText.TrimToMaxChars(memory.ToString(), maxContextChars); |
| } |
|
|
| public bool ShouldCompact(List<ConversationTurn> turns) |
| { |
| if (turns == null) |
| return false; |
|
|
| var olderCount = turns.Count - Mathf.Max(1, RecentTurnCount); |
| return olderCount >= Mathf.Max(1, CompressionThreshold); |
| } |
|
|
| public string BuildSummary(List<ConversationTurn> turns) |
| { |
| var maxSummaryChars = Mathf.Max(2000, Math.Min(12000, MaxContextChars)); |
| var summary = new StringBuilder(); |
| summary.AppendLine("# Conversation Memory Summary"); |
| summary.AppendLine("UpdatedUtc: " + DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture)); |
| summary.AppendLine("CompressedTurns: " + (turns == null ? 0 : turns.Count).ToString(CultureInfo.InvariantCulture)); |
|
|
| var factLines = ExtractMemoryFactLines(turns, 24); |
| if (factLines.Count > 0) |
| { |
| summary.AppendLine(); |
| summary.AppendLine("## Stable User Notes"); |
| for (var i = 0; i < factLines.Count; i++) |
| summary.AppendLine("- " + factLines[i]); |
| } |
|
|
| summary.AppendLine(); |
| summary.AppendLine("## Compressed Timeline"); |
| if (turns != null) |
| { |
| var start = Math.Max(0, turns.Count - 40); |
| for (var i = start; i < turns.Count; i++) |
| AppendTimelineTurn(summary, turns[i]); |
| } |
|
|
| return AgentHarnessText.TrimToMaxChars(summary.ToString(), maxSummaryChars); |
| } |
|
|
| static void AppendMemoryTurn(StringBuilder builder, ConversationTurn turn) |
| { |
| |
| builder.AppendLine(" User: " + AgentHarnessText.OneLine(turn.User, 500)); |
| if (!string.IsNullOrWhiteSpace(turn.Assistant)) |
| builder.AppendLine(" Assistant: " + AgentHarnessText.OneLine(turn.Assistant, 700)); |
| if (turn.Tools != null && turn.Tools.Count > 0) |
| builder.AppendLine(" Tools: " + string.Join(", ", turn.Tools.ToArray())); |
| } |
|
|
| static void AppendTimelineTurn(StringBuilder builder, ConversationTurn turn) |
| { |
| builder.Append("- ").Append(AgentHarnessText.FormatTimestamp(turn.TimestampUtc)).Append(" "); |
| builder.Append("User: ").Append(AgentHarnessText.OneLine(turn.User, 260)); |
| if (!string.IsNullOrWhiteSpace(turn.Assistant)) |
| builder.Append(" | Assistant: ").Append(AgentHarnessText.OneLine(turn.Assistant, 360)); |
| if (turn.Tools != null && turn.Tools.Count > 0) |
| builder.Append(" | Tools: ").Append(string.Join(", ", turn.Tools.ToArray())); |
| builder.AppendLine(); |
| } |
|
|
| static List<string> ExtractMemoryFactLines(List<ConversationTurn> turns, int maxCount) |
| { |
| var facts = new List<string>(); |
| if (turns == null) |
| return facts; |
|
|
| for (var i = turns.Count - 1; i >= 0 && facts.Count < maxCount; i--) |
| { |
| var user = turns[i].User; |
| var normalizedUser = AgentHarnessText.Normalize(user); |
| if (!AgentHarnessText.ContainsAny(normalizedUser, "remember", "preference", "prefer", "like", "always", "never", "기억", "선호", "좋아", "싫어", "원해")) |
| continue; |
|
|
| facts.Add(AgentHarnessText.FormatTimestamp(turns[i].TimestampUtc) + " " + AgentHarnessText.OneLine(user, 300)); |
| } |
|
|
| facts.Reverse(); |
| return facts; |
| } |
| } |
|
|
| public static class AgentHarnessText |
| { |
| public static string Normalize(string text) |
| { |
| return string.IsNullOrWhiteSpace(text) ? string.Empty : text.Trim().ToLowerInvariant(); |
| } |
|
|
| public static bool ContainsAny(string text, params string[] keywords) |
| { |
| if (string.IsNullOrWhiteSpace(text)) |
| return false; |
|
|
| for (var i = 0; i < keywords.Length; i++) |
| { |
| if (!string.IsNullOrWhiteSpace(keywords[i]) && text.Contains(keywords[i].ToLowerInvariant())) |
| return true; |
| } |
|
|
| return false; |
| } |
|
|
| public static string OneLine(string text, int maxChars) |
| { |
| if (string.IsNullOrWhiteSpace(text)) |
| return string.Empty; |
|
|
| var normalized = Regex.Replace(text.Trim(), "\\s+", " "); |
| return TrimToMaxChars(normalized, maxChars); |
| } |
|
|
| public static string TrimToMaxChars(string text, int maxChars) |
| { |
| if (string.IsNullOrEmpty(text) || maxChars <= 0 || text.Length <= maxChars) |
| return text ?? string.Empty; |
|
|
| if (maxChars <= 20) |
| return text.Substring(0, maxChars); |
|
|
| var headLength = Math.Max(8, maxChars / 3); |
| var tailLength = maxChars - headLength - 16; |
| if (tailLength <= 0) |
| return text.Substring(0, maxChars); |
|
|
| return text.Substring(0, headLength) + "\n...[truncated]...\n" + text.Substring(text.Length - tailLength); |
| } |
|
|
| public static string FormatTimestamp(string timestampUtc) |
| { |
| DateTime parsed; |
| if (DateTime.TryParse(timestampUtc, null, DateTimeStyles.RoundtripKind, out parsed)) |
| return parsed.ToLocalTime().ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture); |
|
|
| return string.IsNullOrWhiteSpace(timestampUtc) ? "unknown time" : timestampUtc; |
| } |
| } |
| } |
|
|