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 m_NormalEvents = new Queue(); readonly Queue m_HighPriorityEvents = new Queue(); 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]*(?.+)$", 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 m_Events = new List(); 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 m_CallCounts = new Dictionary(StringComparer.OrdinalIgnoreCase); readonly Dictionary m_ResultCounts = new Dictionary(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) { // FNV-1a 64-bit hash; not a security context. 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 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 turns) { if (turns == null) return false; var olderCount = turns.Count - Mathf.Max(1, RecentTurnCount); return olderCount >= Mathf.Max(1, CompressionThreshold); } public string BuildSummary(List 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) { // No timestamp on purpose: leaking ~now lets the model answer "what time is it?" from context instead of GetCurrentTime. 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 ExtractMemoryFactLines(List turns, int maxCount) { var facts = new List(); 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; } } }